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

Compare changes

Choose any two refs to compare.

+5
.env.example
···
+
# by default, simplelink uses an sqlite db in /data, to use a postgres db, set DATABASE_URl
+
# DATABASE_URL=postgresql://user:password@localhost/dbname
+
SERVER_HOST=127.0.0.1
+
SERVER_PORT=8080
+
JWT_SECRET=change-me-in-production
+70
.github/workflows/docker-image.yml
···
+
name: Docker
+
+
on:
+
schedule:
+
- cron: "38 9 * * *"
+
push:
+
branches: ["main"]
+
tags: ["v*.*.*"]
+
pull_request:
+
branches: ["main"]
+
release:
+
types: [published]
+
+
env:
+
REGISTRY: ghcr.io
+
IMAGE_NAME: ${{ github.repository }}
+
+
jobs:
+
build:
+
runs-on: self-hosted
+
permissions:
+
contents: read
+
packages: write
+
id-token: write
+
+
steps:
+
- name: Checkout repository
+
uses: actions/checkout@v3
+
+
- name: Install cosign
+
if: github.event_name != 'pull_request'
+
uses: sigstore/cosign-installer@v3.8.1
+
with:
+
cosign-release: "v2.4.3"
+
+
- name: Setup Docker buildx
+
uses: docker/setup-buildx-action@v3
+
+
- name: Log into registry ${{ env.REGISTRY }}
+
if: github.event_name != 'pull_request'
+
uses: docker/login-action@v3
+
with:
+
registry: ${{ env.REGISTRY }}
+
username: ${{ github.actor }}
+
password: ${{ secrets.GITHUB_TOKEN }}
+
+
- name: Log in to Docker Hub
+
if: github.event_name != 'pull_request'
+
uses: docker/login-action@v3
+
with:
+
username: ${{ secrets.DOCKER_USERNAME }}
+
password: ${{ secrets.DOCKER_PASSWORD }}
+
+
- name: Extract metadata (tags, labels) for Docker
+
id: meta
+
uses: docker/metadata-action@v5
+
with:
+
images: |
+
${{ env.IMAGE_NAME }}
+
${{ env.REGISTRY }}/${{ github.repository }}
+
+
- name: Build and push Docker image
+
uses: docker/build-push-action@v6
+
with:
+
context: .
+
file: ./Dockerfile
+
platforms: linux/amd64,linux/arm64
+
push: ${{ github.event_name != 'pull_request' }}
+
tags: ${{ steps.meta.outputs.tags }}
+
labels: ${{ steps.meta.outputs.labels }}
+10
.gitignore
···
**/node_modules
node_modules
.env
+
/static
+
/target
+
/release
+
release.tar.gz
+
*.log
+
.DS_STORE
+
admin-setup-token.txt
+
package-lock.json
+
bun.lock
+
*.db*
+3
.preludeignore
···
+
.sqlx
+
.env
+
.env.*
+28
.sqlx/query-8452fbf45386d160bc99ac6c0917a00bf5dad445ef7d484936ce6e0cbe21c965.json
···
+
{
+
"db_name": "PostgreSQL",
+
"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 ",
+
"describe": {
+
"columns": [
+
{
+
"ordinal": 0,
+
"name": "source!",
+
"type_info": "Text"
+
},
+
{
+
"ordinal": 1,
+
"name": "count!",
+
"type_info": "Int8"
+
}
+
],
+
"parameters": {
+
"Left": [
+
"Int4"
+
]
+
},
+
"nullable": [
+
true,
+
null
+
]
+
},
+
"hash": "8452fbf45386d160bc99ac6c0917a00bf5dad445ef7d484936ce6e0cbe21c965"
+
}
+23
.sqlx/query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json
···
+
{
+
"db_name": "PostgreSQL",
+
"query": "SELECT id FROM links WHERE id = $1 AND user_id = $2",
+
"describe": {
+
"columns": [
+
{
+
"ordinal": 0,
+
"name": "id",
+
"type_info": "Int4"
+
}
+
],
+
"parameters": {
+
"Left": [
+
"Int4",
+
"Int4"
+
]
+
},
+
"nullable": [
+
false
+
]
+
},
+
"hash": "a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b"
+
}
+28
.sqlx/query-c723ec75f9ca9482e1bc86108c20bf379e5728f378626198a0a9ed024a413273.json
···
+
{
+
"db_name": "PostgreSQL",
+
"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 ",
+
"describe": {
+
"columns": [
+
{
+
"ordinal": 0,
+
"name": "date!",
+
"type_info": "Date"
+
},
+
{
+
"ordinal": 1,
+
"name": "clicks!",
+
"type_info": "Int8"
+
}
+
],
+
"parameters": {
+
"Left": [
+
"Int4"
+
]
+
},
+
"nullable": [
+
null,
+
null
+
]
+
},
+
"hash": "c723ec75f9ca9482e1bc86108c20bf379e5728f378626198a0a9ed024a413273"
+
}
+14
.sqlx/query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json
···
+
{
+
"db_name": "PostgreSQL",
+
"query": "DELETE FROM links WHERE id = $1",
+
"describe": {
+
"columns": [],
+
"parameters": {
+
"Left": [
+
"Int4"
+
]
+
},
+
"nullable": []
+
},
+
"hash": "d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85"
+
}
+14
.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json
···
+
{
+
"db_name": "PostgreSQL",
+
"query": "DELETE FROM clicks WHERE link_id = $1",
+
"describe": {
+
"columns": [],
+
"parameters": {
+
"Left": [
+
"Int4"
+
]
+
},
+
"nullable": []
+
},
+
"hash": "eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5"
+
}
+20
.sqlx/query-fd64104d130b93dd5fc9414b8710ad5183b647eaaff90decbce15e10d83c7538.json
···
+
{
+
"db_name": "PostgreSQL",
+
"query": "SELECT COUNT(*) as count FROM users",
+
"describe": {
+
"columns": [
+
{
+
"ordinal": 0,
+
"name": "count",
+
"type_info": "Int8"
+
}
+
],
+
"parameters": {
+
"Left": []
+
},
+
"nullable": [
+
null
+
]
+
},
+
"hash": "fd64104d130b93dd5fc9414b8710ad5183b647eaaff90decbce15e10d83c7538"
+
}
+245 -300
Cargo.lock
···
version = 4
[[package]]
-
name = "SimpleLink"
-
version = "0.1.0"
-
dependencies = [
-
"actix-cors",
-
"actix-web",
-
"anyhow",
-
"argon2",
-
"base62",
-
"chrono",
-
"clap",
-
"dotenv",
-
"jsonwebtoken",
-
"lazy_static",
-
"regex",
-
"serde",
-
"serde_json",
-
"sqlx",
-
"thiserror 1.0.69",
-
"tokio",
-
"tracing",
-
"tracing-subscriber",
-
"uuid",
-
]
-
-
[[package]]
name = "actix-codec"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "actix-files"
+
version = "0.6.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0773d59061dedb49a8aed04c67291b9d8cf2fe0b60130a381aab53c6dd86e9be"
+
dependencies = [
+
"actix-http",
+
"actix-service",
+
"actix-utils",
+
"actix-web",
+
"bitflags",
+
"bytes",
+
"derive_more",
+
"futures-core",
+
"http-range",
+
"log",
+
"mime",
+
"mime_guess",
+
"percent-encoding",
+
"pin-project-lite",
+
"v_htmlescape",
+
]
+
+
[[package]]
name = "actix-http"
version = "3.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb"
dependencies = [
"quote",
-
"syn 2.0.96",
+
"syn",
]
[[package]]
···
"actix-router",
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
]
[[package]]
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c"
dependencies = [
-
"heck 0.5.0",
+
"heck",
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
]
[[package]]
···
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
+
name = "concurrent-queue"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+
dependencies = [
+
"crossbeam-utils",
+
]
+
+
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
-
name = "core-foundation"
-
version = "0.9.4"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
-
dependencies = [
-
"core-foundation-sys",
-
"libc",
-
]
-
-
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"proc-macro2",
"quote",
"rustc_version",
-
"syn 2.0.96",
+
"syn",
]
[[package]]
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
]
[[package]]
···
[[package]]
name = "event-listener"
-
version = "2.5.3"
+
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
+
checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae"
+
dependencies = [
+
"concurrent-queue",
+
"parking",
+
"pin-project-lite",
+
]
[[package]]
name = "fastrand"
···
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
-
name = "foreign-types"
-
version = "0.3.2"
+
name = "foldhash"
+
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
-
dependencies = [
-
"foreign-types-shared",
-
]
-
-
[[package]]
-
name = "foreign-types-shared"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
[[package]]
name = "form_urlencoded"
···
]
[[package]]
+
name = "futures"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
+
dependencies = [
+
"futures-channel",
+
"futures-core",
+
"futures-executor",
+
"futures-io",
+
"futures-sink",
+
"futures-task",
+
"futures-util",
+
]
+
+
[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
+
name = "futures-macro"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn",
+
]
+
+
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
+
"futures-channel",
"futures-core",
"futures-io",
+
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
···
[[package]]
name = "hashbrown"
-
version = "0.14.5"
+
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
-
"ahash",
"allocator-api2",
+
"equivalent",
+
"foldhash",
]
[[package]]
-
name = "hashbrown"
-
version = "0.15.2"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
-
-
[[package]]
name = "hashlink"
-
version = "0.8.4"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
-
dependencies = [
-
"hashbrown 0.14.5",
-
]
-
-
[[package]]
-
name = "heck"
-
version = "0.4.1"
+
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
-
"unicode-segmentation",
+
"hashbrown",
[[package]]
···
[[package]]
+
name = "http-range"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
+
+
[[package]]
name = "httparse"
version = "1.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
[[package]]
···
checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652"
dependencies = [
"equivalent",
-
"hashbrown 0.15.2",
+
"hashbrown",
[[package]]
···
[[package]]
name = "libsqlite3-sys"
-
version = "0.27.0"
+
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716"
+
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
···
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
-
name = "minimal-lexical"
-
version = "0.2.1"
+
name = "mime_guess"
+
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+
dependencies = [
+
"mime",
+
"unicase",
+
]
[[package]]
name = "miniz_oxide"
···
[[package]]
-
name = "native-tls"
-
version = "0.2.12"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
-
dependencies = [
-
"libc",
-
"log",
-
"openssl",
-
"openssl-probe",
-
"openssl-sys",
-
"schannel",
-
"security-framework",
-
"security-framework-sys",
-
"tempfile",
-
]
-
-
[[package]]
-
name = "nom"
-
version = "7.1.3"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
-
dependencies = [
-
"memchr",
-
"minimal-lexical",
-
]
-
-
[[package]]
name = "nu-ansi-term"
-
version = "0.46.0"
+
version = "0.50.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
+
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
dependencies = [
-
"overload",
-
"winapi",
+
"windows-sys 0.52.0",
[[package]]
···
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
-
name = "openssl"
-
version = "0.10.68"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5"
-
dependencies = [
-
"bitflags",
-
"cfg-if",
-
"foreign-types",
-
"libc",
-
"once_cell",
-
"openssl-macros",
-
"openssl-sys",
-
]
-
-
[[package]]
-
name = "openssl-macros"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
-
dependencies = [
-
"proc-macro2",
-
"quote",
-
"syn 2.0.96",
-
]
-
-
[[package]]
-
name = "openssl-probe"
-
version = "0.1.6"
+
name = "parking"
+
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
-
-
[[package]]
-
name = "openssl-sys"
-
version = "0.9.104"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741"
-
dependencies = [
-
"cc",
-
"libc",
-
"pkg-config",
-
"vcpkg",
-
]
-
-
[[package]]
-
name = "overload"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
+
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
···
[[package]]
name = "ring"
-
version = "0.17.8"
+
version = "0.17.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
+
checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
-
"spin",
"untrusted",
"windows-sys 0.52.0",
···
[[package]]
+
name = "rust-embed"
+
version = "6.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661"
+
dependencies = [
+
"rust-embed-impl",
+
"rust-embed-utils",
+
"walkdir",
+
]
+
+
[[package]]
+
name = "rust-embed-impl"
+
version = "6.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"rust-embed-utils",
+
"syn",
+
"walkdir",
+
]
+
+
[[package]]
+
name = "rust-embed-utils"
+
version = "7.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74"
+
dependencies = [
+
"sha2",
+
"walkdir",
+
]
+
+
[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
-
name = "schannel"
-
version = "0.1.27"
+
name = "same-file"
+
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
+
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
-
"windows-sys 0.59.0",
+
"winapi-util",
[[package]]
···
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
-
name = "security-framework"
-
version = "2.11.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
-
dependencies = [
-
"bitflags",
-
"core-foundation",
-
"core-foundation-sys",
-
"libc",
-
"security-framework-sys",
-
]
-
-
[[package]]
-
name = "security-framework-sys"
-
version = "2.14.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
-
dependencies = [
-
"core-foundation-sys",
-
"libc",
-
]
-
-
[[package]]
name = "semver"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
[[package]]
···
[[package]]
+
name = "simplelink"
+
version = "0.1.0"
+
dependencies = [
+
"actix-cors",
+
"actix-files",
+
"actix-web",
+
"anyhow",
+
"argon2",
+
"base62",
+
"chrono",
+
"clap",
+
"dotenv",
+
"futures",
+
"jsonwebtoken",
+
"lazy_static",
+
"mime_guess",
+
"rand",
+
"regex",
+
"rust-embed",
+
"serde",
+
"serde_json",
+
"sqlx",
+
"thiserror 1.0.69",
+
"tokio",
+
"tracing",
+
"tracing-subscriber",
+
"uuid",
+
]
+
+
[[package]]
name = "slab"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
+
dependencies = [
+
"serde",
+
]
[[package]]
name = "socket2"
···
[[package]]
-
name = "sqlformat"
-
version = "0.2.6"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790"
-
dependencies = [
-
"nom",
-
"unicode_categories",
-
]
-
-
[[package]]
name = "sqlx"
-
version = "0.7.4"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa"
+
checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f"
dependencies = [
"sqlx-core",
"sqlx-macros",
···
[[package]]
name = "sqlx-core"
-
version = "0.7.4"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6"
+
checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0"
dependencies = [
-
"ahash",
-
"atoi",
-
"byteorder",
"bytes",
"chrono",
"crc",
"crossbeam-queue",
"either",
"event-listener",
-
"futures-channel",
"futures-core",
"futures-intrusive",
"futures-io",
"futures-util",
+
"hashbrown",
"hashlink",
-
"hex",
"indexmap",
"log",
"memchr",
-
"native-tls",
"once_cell",
-
"paste",
"percent-encoding",
"serde",
"serde_json",
"sha2",
"smallvec",
-
"sqlformat",
-
"thiserror 1.0.69",
+
"thiserror 2.0.11",
"tokio",
"tokio-stream",
"tracing",
"url",
-
"uuid",
[[package]]
name = "sqlx-macros"
-
version = "0.7.4"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127"
+
checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310"
dependencies = [
"proc-macro2",
"quote",
"sqlx-core",
"sqlx-macros-core",
-
"syn 1.0.109",
+
"syn",
[[package]]
name = "sqlx-macros-core"
-
version = "0.7.4"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8"
+
checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad"
dependencies = [
"dotenvy",
"either",
-
"heck 0.4.1",
+
"heck",
"hex",
"once_cell",
"proc-macro2",
···
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
-
"syn 1.0.109",
+
"syn",
"tempfile",
"tokio",
"url",
···
[[package]]
name = "sqlx-mysql"
-
version = "0.7.4"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
+
checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233"
dependencies = [
"atoi",
-
"base64 0.21.7",
+
"base64 0.22.1",
"bitflags",
"byteorder",
"bytes",
···
"smallvec",
"sqlx-core",
"stringprep",
-
"thiserror 1.0.69",
+
"thiserror 2.0.11",
"tracing",
-
"uuid",
"whoami",
[[package]]
name = "sqlx-postgres"
-
version = "0.7.4"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
+
checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613"
dependencies = [
"atoi",
-
"base64 0.21.7",
+
"base64 0.22.1",
"bitflags",
"byteorder",
"chrono",
···
"etcetera",
"futures-channel",
"futures-core",
-
"futures-io",
"futures-util",
"hex",
"hkdf",
···
"smallvec",
"sqlx-core",
"stringprep",
-
"thiserror 1.0.69",
+
"thiserror 2.0.11",
"tracing",
-
"uuid",
"whoami",
[[package]]
name = "sqlx-sqlite"
-
version = "0.7.4"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa"
+
checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540"
dependencies = [
"atoi",
"chrono",
···
"log",
"percent-encoding",
"serde",
+
"serde_urlencoded",
"sqlx-core",
"tracing",
"url",
-
"urlencoding",
-
"uuid",
[[package]]
···
[[package]]
name = "syn"
-
version = "1.0.109"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
-
dependencies = [
-
"proc-macro2",
-
"quote",
-
"unicode-ident",
-
]
-
-
[[package]]
-
name = "syn"
version = "2.0.96"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80"
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
[[package]]
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
[[package]]
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
[[package]]
···
[[package]]
name = "tokio"
-
version = "1.43.0"
+
version = "1.43.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e"
+
checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c"
dependencies = [
"backtrace",
"bytes",
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
[[package]]
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
[[package]]
···
[[package]]
name = "tracing-subscriber"
-
version = "0.3.19"
+
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
+
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
dependencies = [
"nu-ansi-term",
"sharded-slab",
···
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+
+
[[package]]
+
name = "unicase"
+
version = "2.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-bidi"
···
checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
[[package]]
-
name = "unicode-segmentation"
-
version = "1.12.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
-
-
[[package]]
-
name = "unicode_categories"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
-
-
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
-
name = "urlencoding"
-
version = "2.1.3"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
-
-
[[package]]
name = "utf16_iter"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b"
dependencies = [
"getrandom",
-
"serde",
[[package]]
+
name = "v_htmlescape"
+
version = "0.15.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
+
+
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
+
name = "walkdir"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+
dependencies = [
+
"same-file",
+
"winapi-util",
+
]
+
+
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"log",
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
"wasm-bindgen-shared",
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
···
[[package]]
-
name = "winapi"
-
version = "0.3.9"
+
name = "winapi-util"
+
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
-
"winapi-i686-pc-windows-gnu",
-
"winapi-x86_64-pc-windows-gnu",
+
"windows-sys 0.59.0",
-
-
[[package]]
-
name = "winapi-i686-pc-windows-gnu"
-
version = "0.4.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-
[[package]]
-
name = "winapi-x86_64-pc-windows-gnu"
-
version = "0.4.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
"synstructure",
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
[[package]]
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
"synstructure",
···
dependencies = [
"proc-macro2",
"quote",
-
"syn 2.0.96",
+
"syn",
[[package]]
+10 -5
Cargo.toml
···
[package]
-
name = "SimpleLink"
+
name = "simplelink"
version = "0.1.0"
edition = "2021"
[lib]
-
name = "simple_link"
+
name = "simplelink"
path = "src/lib.rs"
[dependencies]
+
rust-embed = "6.8"
jsonwebtoken = "9"
actix-web = "4.4"
+
actix-files = "0.6"
actix-cors = "0.6"
-
tokio = { version = "1.36", features = ["full"] }
-
sqlx = { version = "0.7", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono"] }
+
tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] }
+
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "chrono"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
-
uuid = { version = "1.7", features = ["v4", "serde"] }
+
uuid = { version = "1.7", features = ["v4"] } # Remove serde if not using UUID serialization
base62 = "2.0"
clap = { version = "4.5", features = ["derive"] }
dotenv = "0.15"
···
regex = "1.10"
lazy_static = "1.4"
argon2 = "0.5.3"
+
rand = { version = "0.8", features = ["std"] }
+
mime_guess = "2.0.5"
+
futures = "0.3.31"
+68
Dockerfile
···
+
# Frontend build stage
+
FROM oven/bun:latest AS frontend-builder
+
+
WORKDIR /usr/src/frontend
+
+
# Copy frontend files
+
COPY frontend/package.json ./
+
RUN bun install
+
+
COPY frontend/ ./
+
+
# Build frontend with production configuration
+
ARG API_URL=http://localhost:8080
+
ENV VITE_API_URL=${API_URL}
+
RUN bun run build
+
+
# Rust build stage
+
FROM rust:latest AS backend-builder
+
+
# Install PostgreSQL client libraries and SSL dependencies
+
RUN apt-get update && \
+
apt-get install -y pkg-config libssl-dev libpq-dev && \
+
rm -rf /var/lib/apt/lists/*
+
+
WORKDIR /usr/src/app
+
+
# Copy manifests first (better layer caching)
+
COPY Cargo.toml Cargo.lock ./
+
+
# Copy source code and SQLx prepared queries
+
COPY src/ src/
+
COPY migrations/ migrations/
+
COPY .sqlx/ .sqlx/
+
+
# Create static directory and copy frontend build
+
COPY --from=frontend-builder /usr/src/frontend/dist/ static/
+
+
# Build the application
+
RUN cargo build --release
+
+
# Runtime stage
+
FROM debian:bookworm-slim
+
+
# Install runtime dependencies
+
RUN apt-get update && \
+
apt-get install -y libpq5 ca-certificates openssl libssl3 && \
+
rm -rf /var/lib/apt/lists/*
+
+
WORKDIR /app
+
+
# Copy the binary from builder
+
COPY --from=backend-builder /usr/src/app/target/release/simplelink /app/simplelink
+
+
# Copy migrations folder for SQLx
+
COPY --from=backend-builder /usr/src/app/migrations /app/migrations
+
+
# Copy static files
+
COPY --from=backend-builder /usr/src/app/static /app/static
+
+
# Expose the port
+
EXPOSE 8080
+
+
# Set default network configuration
+
ENV SERVER_HOST=0.0.0.0
+
ENV SERVER_PORT=8080
+
+
# Run the binary
+
CMD ["./simplelink"]
+80
README.md
···
+
# SimpleLink
+
+
A very performant and light (2MB in memory) link shortener and tracker. Written in Rust and React and uses Postgres or SQLite.
+
+
![MainView](readme_img/mainview.jpg)
+
+
![StatsView](readme_img/statview.jpg)
+
+
## How to Run
+
+
### From Docker
+
+
```bash
+
docker run -p 8080:8080 \
+
-e JWT_SECRET=change-me-in-production \
+
-e SIMPLELINK_USER=admin@example.com \
+
-e SIMPLELINK_PASS=your-secure-password \
+
-v simplelink_data:/data \
+
ghcr.io/waveringana/simplelink:v2.2
+
```
+
+
### Environment Variables
+
+
- `JWT_SECRET`: Required. Used for JWT token generation
+
- `SIMPLELINK_USER`: Optional. If set along with SIMPLELINK_PASS, creates an admin user on first run
+
- `SIMPLELINK_PASS`: Optional. Admin user password
+
- `DATABASE_URL`: Optional. Postgres connection string. If not set, uses SQLite
+
- `INITIAL_LINKS`: Optional. Semicolon-separated list of initial links in format "url,code;url2,code2"
+
- `SERVER_HOST`: Optional. Default: "127.0.0.1"
+
- `SERVER_PORT`: Optional. Default: "8080"
+
+
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.
+
+
### From Docker Compose
+
+
Edit the docker-compose.yml file. It comes included with a PostgreSQL db configuration.
+
+
## Build
+
+
### From Source
+
+
First configure .env.example and save it to .env
+
+
```bash
+
git clone https://github.com/waveringana/simplelink && cd simplelink
+
./build.sh
+
cargo run
+
```
+
+
Alternatively for a binary build:
+
+
```bash
+
./build.sh --binary
+
```
+
+
then check /target/release for the binary named `SimpleGit`
+
+
### From Docker
+
+
```bash
+
docker build -t simplelink .
+
docker run -p 8080:8080 \
+
-e JWT_SECRET=change-me-in-production \
+
-e SIMPLELINK_USER=admin@example.com \
+
-e SIMPLELINK_PASS=your-secure-password \
+
-v simplelink_data:/data \
+
simplelink
+
```
+
+
### From Docker Compose
+
+
Adjust the included docker-compose.yml to your liking; it includes a postgres config as well.
+
+
## Features
+
+
- Support for both PostgreSQL and SQLite databases
+
- Initial links can be configured via environment variables
+
- Admin user can be created on first run via environment variables
+
- Link click tracking and statistics
+
- Lightweight and performant
+89
build.sh
···
+
#!/bin/bash
+
+
# Default values
+
#API_URL="http://localhost:8080"
+
RELEASE_MODE=false
+
BINARY_MODE=false
+
+
# Parse command line arguments
+
for arg in "$@"
+
do
+
case $arg in
+
#api-domain=*)
+
#API_URL="${arg#*=}"
+
#shift
+
#;;
+
--release)
+
RELEASE_MODE=true
+
shift
+
;;
+
--binary)
+
BINARY_MODE=true
+
shift
+
;;
+
esac
+
done
+
+
#echo "Building project with API_URL: $API_URL"
+
echo "Release mode: $RELEASE_MODE"
+
+
# Check if cargo is installed
+
if ! command -v cargo &> /dev/null; then
+
echo "cargo is not installed. Please install Rust and cargo first."
+
exit 1
+
fi
+
+
# Check if npm is installed
+
if ! command -v npm &> /dev/null; then
+
echo "npm is not installed. Please install Node.js and npm first."
+
exit 1
+
fi
+
+
# Build frontend
+
echo "Building frontend..."
+
# Create .env file for Vite
+
#echo "VITE_API_URL=$API_URL" > frontend/.env
+
+
# Install frontend dependencies and build
+
cd frontend
+
npm install
+
npm run build
+
cd ..
+
+
# Create static directory and copy frontend build
+
mkdir -p static
+
rm -rf static/*
+
cp -r frontend/dist/* static/
+
+
# Build Rust project
+
echo "Building Rust project..."
+
if [ "$RELEASE_MODE" = true ]; then
+
cargo build --release
+
+
# Create release directory
+
mkdir -p release
+
+
# Copy only the binary to release directory
+
cp target/release/simplelink release/
+
cp .env.example release/.env
+
+
# Create a tar archive
+
tar -czf release.tar.gz release/
+
+
echo "Release archive created: release.tar.gz"
+
elif [ "$BINARY_MODE" = true ]; then
+
cargo build --release
+
else
+
cargo build
+
fi
+
+
echo "Build complete!"
+
echo "To run the project:"
+
if [ "$RELEASE_MODE" = true ]; then
+
echo "1. Extract release.tar.gz"
+
echo "2. Configure .env file"
+
echo "3. Run ./simplelink"
+
else
+
echo "1. Configure .env file"
+
echo "2. Run 'cargo run'"
+
fi
+33 -2
docker-compose.yml
···
-
version: '3.8'
services:
db:
image: postgres:15-alpine
···
interval: 5s
timeout: 5s
retries: 5
+
networks:
+
- shortener-network
+
+
app:
+
image: ghcr.io/waveringana/simplelink:v2.2
+
container_name: shortener-app
+
ports:
+
- "8080:8080"
+
environment:
+
- DATABASE_URL=postgresql://shortener:shortener123@db:5432/shortener
+
- SERVER_HOST=0.0.0.0
+
- SERVER_PORT=8080
+
- JWT_SECRET=change-me-in-production
+
depends_on:
+
db:
+
condition: service_healthy
+
healthcheck:
+
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
+
interval: 30s
+
timeout: 10s
+
retries: 3
+
start_period: 40s
+
networks:
+
- shortener-network
+
deploy:
+
restart_policy:
+
condition: on-failure
+
max_attempts: 3
+
window: 120s
+
+
networks:
+
shortener-network:
+
driver: bridge
volumes:
shortener-data:
-
+2
frontend/.gitignore
···
*.njsproj
*.sln
*.sw?
+
+
.sqlx
+3
frontend/.preludeignore
···
+
bun.lock
+
*.json
+
*.js
-622
frontend/bun.lock
···
-
{
-
"lockfileVersion": 1,
-
"workspaces": {
-
"": {
-
"name": "frontend",
-
"dependencies": {
-
"@emotion/react": "^11.14.0",
-
"@mantine/core": "^7.16.1",
-
"@mantine/form": "^7.16.1",
-
"@mantine/hooks": "^7.16.1",
-
"axios": "^1.7.9",
-
"react": "^18.3.1",
-
"react-dom": "^18.3.1",
-
},
-
"devDependencies": {
-
"@eslint/js": "^9.17.0",
-
"@types/node": "^22.10.10",
-
"@types/react": "^18.3.18",
-
"@types/react-dom": "^18.3.5",
-
"@vitejs/plugin-react": "^4.3.4",
-
"eslint": "^9.17.0",
-
"eslint-plugin-react-hooks": "^5.0.0",
-
"eslint-plugin-react-refresh": "^0.4.16",
-
"globals": "^15.14.0",
-
"typescript": "~5.6.2",
-
"typescript-eslint": "^8.18.2",
-
"vite": "^6.0.5",
-
},
-
},
-
},
-
"packages": {
-
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
-
-
"@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=="],
-
-
"@babel/compat-data": ["@babel/compat-data@7.26.5", "", {}, "sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw=="],
-
-
"@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=="],
-
-
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.26.5", "", {}, "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg=="],
-
-
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="],
-
-
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="],
-
-
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.25.9", "", {}, "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw=="],
-
-
"@babel/helpers": ["@babel/helpers@7.26.7", "", { "dependencies": { "@babel/template": "^7.25.9", "@babel/types": "^7.26.7" } }, "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A=="],
-
-
"@babel/parser": ["@babel/parser@7.26.7", "", { "dependencies": { "@babel/types": "^7.26.7" }, "bin": "./bin/babel-parser.js" }, "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@babel/runtime": ["@babel/runtime@7.26.7", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="],
-
-
"@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="],
-
-
"@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="],
-
-
"@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=="],
-
-
"@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="],
-
-
"@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="],
-
-
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="],
-
-
"@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="],
-
-
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="],
-
-
"@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="],
-
-
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="],
-
-
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="],
-
-
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="],
-
-
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="],
-
-
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="],
-
-
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="],
-
-
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="],
-
-
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="],
-
-
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="],
-
-
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="],
-
-
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="],
-
-
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="],
-
-
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="],
-
-
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="],
-
-
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="],
-
-
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="],
-
-
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="],
-
-
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="],
-
-
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="],
-
-
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="],
-
-
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="],
-
-
"@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=="],
-
-
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
-
-
"@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=="],
-
-
"@eslint/core": ["@eslint/core@0.10.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw=="],
-
-
"@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=="],
-
-
"@eslint/js": ["@eslint/js@9.19.0", "", {}, "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ=="],
-
-
"@eslint/object-schema": ["@eslint/object-schema@2.1.5", "", {}, "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ=="],
-
-
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.2.5", "", { "dependencies": { "@eslint/core": "^0.10.0", "levn": "^0.4.1" } }, "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A=="],
-
-
"@floating-ui/core": ["@floating-ui/core@1.6.9", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw=="],
-
-
"@floating-ui/dom": ["@floating-ui/dom@1.6.13", "", { "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
-
-
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
-
-
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
-
-
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
-
-
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.1", "", {}, "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA=="],
-
-
"@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=="],
-
-
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
-
-
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
-
-
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@mantine/hooks": ["@mantine/hooks@7.16.1", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-+hER8E4d2ByfQ/DKIXGM3Euxb7IH5ArSjzzzoF21sG095iXIryOCob22ZanrmiXCoAzKKdxqgVj4Di67ikLYSQ=="],
-
-
"@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=="],
-
-
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
-
-
"@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=="],
-
-
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.32.0", "", { "os": "android", "cpu": "arm" }, "sha512-G2fUQQANtBPsNwiVFg4zKiPQyjVKZCUdQUol53R8E71J7AsheRMV/Yv/nB8giOcOVqP7//eB5xPqieBYZe9bGg=="],
-
-
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-qhFwQ+ljoymC+j5lXRv8DlaJYY/+8vyvYmVx074zrLsu5ZGWYsJNLjPPVJJjhZQpyAKUGPydOq9hRLLNvh1s3A=="],
-
-
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-44n/X3lAlWsEY6vF8CzgCx+LQaoqWGN7TzUfbJDiTIOjJm4+L2Yq+r5a8ytQRGyPqgJDs3Rgyo8eVL7n9iW6AQ=="],
-
-
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-F9ct0+ZX5Np6+ZDztxiGCIvlCaW87HBdHcozUfsHnj1WCUTBUubAoanhHUfnUHZABlElyRikI0mgcw/qdEm2VQ=="],
-
-
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.32.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-JpsGxLBB2EFXBsTLHfkZDsXSpSmKD3VxXCgBQtlPcuAqB8TlqtLcbeMhxXQkCDv1avgwNjF8uEIbq5p+Cee0PA=="],
-
-
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wegiyBT6rawdpvnD9lmbOpx5Sph+yVZKHbhnSP9MqUEDX08G4UzMU+D87jrazGE7lRSyTRs6NEYHtzfkJ3FjjQ=="],
-
-
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-3pA7xecItbgOs1A5H58dDvOUEboG5UfpTq3WzAdF54acBbUM+olDJAPkgj1GRJ4ZqE12DZ9/hNS2QZk166v92A=="],
-
-
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Y7XUZEVISGyge51QbYyYAEHwpGgmRrAxQXO3siyYo2kmaj72USSG8LtlQQgAtlGfxYiOwu+2BdbPjzEpcOpRmQ=="],
-
-
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-r7/OTF5MqeBrZo5omPXcTnjvv1GsrdH8a8RerARvDFiDwFpDVDnJyByYM/nX+mvks8XXsgPUxkwe/ltaX2VH7w=="],
-
-
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HJbifC9vex9NqnlodV2BHVFNuzKL5OnsV2dvTw6e1dpZKkNjPG6WUq+nhEYV6Hv2Bv++BXkwcyoGlXnPrjAKXw=="],
-
-
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.32.0", "", { "os": "linux", "cpu": "none" }, "sha512-VAEzZTD63YglFlWwRj3taofmkV1V3xhebDXffon7msNz4b14xKsz7utO6F8F4cqt8K/ktTl9rm88yryvDpsfOw=="],
-
-
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.32.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Sts5DST1jXAc9YH/iik1C9QRsLcCoOScf3dfbY5i4kH9RJpKxiTBXqm7qU5O6zTXBTEZry69bGszr3SMgYmMcQ=="],
-
-
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.32.0", "", { "os": "linux", "cpu": "none" }, "sha512-qhlXeV9AqxIyY9/R1h1hBD6eMvQCO34ZmdYvry/K+/MBs6d1nRFLm6BOiITLVI+nFAAB9kUB6sdJRKyVHXnqZw=="],
-
-
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.32.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8ZGN7ExnV0qjXa155Rsfi6H8M4iBBwNLBM9lcVS+4NcSzOFaNqmt7djlox8pN1lWrRPMRRQ8NeDlozIGx3Omsw=="],
-
-
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-VDzNHtLLI5s7xd/VubyS10mq6TxvZBp+4NRWoW+Hi3tgV05RtVm4qK99+dClwTN1McA6PHwob6DEJ6PlXbY83A=="],
-
-
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-qcb9qYDlkxz9DxJo7SDhWxTWV1gFuwznjbTiov289pASxlfGbaOD54mgbs9+z94VwrXtKTu+2RqwlSTbiOqxGg=="],
-
-
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-pFDdotFDMXW2AXVbfdUEfidPAk/OtwE/Hd4eYMTNVVaCQ6Yl8et0meDaKNL63L44Haxv4UExpv9ydSf3aSayDg=="],
-
-
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.32.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-/TG7WfrCAjeRNDvI4+0AAMoHxea/USWhAzf9PVDFHbcqrQ7hMMKp4jZIy4VEjk72AAfN5k4TiSMRXRKf/0akSw=="],
-
-
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5hqO5S3PTEO2E5VjCePxv40gIgyS2KvO7E7/vvC/NbIW4SIRamkMr1hqj+5Y67fbBWv/bQLB6KelBQmXlyCjWA=="],
-
-
"@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=="],
-
-
"@types/babel__generator": ["@types/babel__generator@7.6.8", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw=="],
-
-
"@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=="],
-
-
"@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="],
-
-
"@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
-
-
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
-
-
"@types/node": ["@types/node@22.10.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww=="],
-
-
"@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
-
-
"@types/prop-types": ["@types/prop-types@15.7.14", "", {}, "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="],
-
-
"@types/react": ["@types/react@18.3.18", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ=="],
-
-
"@types/react-dom": ["@types/react-dom@18.3.5", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@typescript-eslint/types": ["@typescript-eslint/types@8.21.0", "", {}, "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"@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=="],
-
-
"acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
-
-
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
-
-
"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=="],
-
-
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
-
-
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
-
-
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
-
-
"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=="],
-
-
"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=="],
-
-
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
-
-
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
-
-
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
-
-
"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=="],
-
-
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
-
-
"caniuse-lite": ["caniuse-lite@1.0.30001695", "", {}, "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw=="],
-
-
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
-
-
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
-
-
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
-
-
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
-
-
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
-
-
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
-
-
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
-
-
"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=="],
-
-
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
-
-
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
-
-
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
-
-
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
-
-
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
-
-
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
-
-
"electron-to-chromium": ["electron-to-chromium@1.5.88", "", {}, "sha512-K3C2qf1o+bGzbilTDCTBhTQcMS9KW60yTAaTeeXsfvQuTDDwlokLam/AdqlqcSy9u4UainDgsHV23ksXAOgamw=="],
-
-
"error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="],
-
-
"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=="],
-
-
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
-
-
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
-
-
"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=="],
-
-
"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=="],
-
-
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.18", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-IRGEoFn3OKalm3hjfolEWGqoF/jPqeEYFp+C8B0WMzwGwBMvlRDQd06kghDhF0C61uJ6WfSDhEZE/sAQjduKgw=="],
-
-
"eslint-scope": ["eslint-scope@8.2.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A=="],
-
-
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
-
-
"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=="],
-
-
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
-
-
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
-
-
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
-
-
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
-
-
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
-
-
"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=="],
-
-
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
-
-
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
-
-
"fastq": ["fastq@1.18.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw=="],
-
-
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
-
-
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
-
-
"find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
-
-
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
-
-
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
-
-
"flatted": ["flatted@3.3.2", "", {}, "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA=="],
-
-
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
-
-
"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=="],
-
-
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
-
-
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
-
-
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
-
-
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
-
-
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
-
-
"globals": ["globals@15.14.0", "", {}, "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig=="],
-
-
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
-
-
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
-
-
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
-
-
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
-
-
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
-
-
"import-fresh": ["import-fresh@3.3.0", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw=="],
-
-
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
-
-
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
-
-
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
-
-
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
-
-
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
-
-
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
-
-
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
-
-
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
-
-
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
-
-
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
-
-
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
-
-
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
-
-
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
-
-
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
-
-
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
-
-
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
-
-
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
-
-
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
-
-
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
-
-
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
-
-
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
-
-
"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=="],
-
-
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
-
-
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
-
-
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
-
-
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
-
-
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
-
-
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
-
-
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
-
-
"nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="],
-
-
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
-
-
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
-
-
"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=="],
-
-
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
-
-
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
-
-
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
-
-
"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=="],
-
-
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
-
-
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
-
-
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
-
-
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
-
-
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
-
-
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
-
-
"postcss": ["postcss@8.5.1", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ=="],
-
-
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
-
-
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
-
-
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
-
-
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
-
-
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
-
-
"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=="],
-
-
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
-
-
"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=="],
-
-
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
-
-
"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=="],
-
-
"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=="],
-
-
"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=="],
-
-
"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=="],
-
-
"regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
-
-
"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=="],
-
-
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
-
-
"reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="],
-
-
"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=="],
-
-
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
-
-
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
-
-
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
-
-
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
-
-
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
-
-
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
-
-
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
-
-
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
-
-
"stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
-
-
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
-
-
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
-
-
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
-
-
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
-
-
"ts-api-utils": ["ts-api-utils@2.0.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ=="],
-
-
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
-
-
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
-
-
"type-fest": ["type-fest@4.33.0", "", {}, "sha512-s6zVrxuyKbbAsSAD5ZPTB77q4YIdRctkTbJ2/Dqlinwz+8ooH2gd+YA7VA6Pa93KML9GockVvoxjZ2vHP+mu8g=="],
-
-
"typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
-
-
"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=="],
-
-
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
-
-
"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=="],
-
-
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
-
-
"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=="],
-
-
"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=="],
-
-
"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=="],
-
-
"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=="],
-
-
"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=="],
-
-
"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=="],
-
-
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
-
-
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
-
-
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
-
-
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
-
-
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
-
-
"@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
-
-
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
-
-
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
-
-
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
-
-
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
-
-
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
-
-
"@typescript-eslint/typescript-estree/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
-
-
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
-
-
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
-
}
-
}
+21
frontend/components.json
···
+
{
+
"$schema": "https://ui.shadcn.com/schema.json",
+
"style": "new-york",
+
"rsc": false,
+
"tsx": true,
+
"tailwind": {
+
"config": "tailwind.config.js",
+
"css": "src/index.css",
+
"baseColor": "stone",
+
"cssVariables": true,
+
"prefix": ""
+
},
+
"aliases": {
+
"components": "@/components",
+
"utils": "@/lib/utils",
+
"ui": "@/components/ui",
+
"lib": "@/lib",
+
"hooks": "@/hooks"
+
},
+
"iconLibrary": "lucide"
+
}
+14 -11
frontend/index.html
···
<!doctype html>
<html lang="en">
-
<head>
-
<meta charset="UTF-8" />
-
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
-
<title>Vite + React + TS</title>
-
</head>
-
<body>
-
<div id="root"></div>
-
<script type="module" src="/src/main.tsx"></script>
-
</body>
-
</html>
+
+
<head>
+
<meta charset="UTF-8" />
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<title>SimpleLink</title>
+
</head>
+
+
<body>
+
<div id="root"></div>
+
<script type="module" src="/src/main.tsx"></script>
+
</body>
+
+
</html>
+21 -1
frontend/package.json
···
},
"dependencies": {
"@emotion/react": "^11.14.0",
+
"@hookform/resolvers": "^3.10.0",
+
"@icons-pack/react-simple-icons": "^11.2.0",
"@mantine/core": "^7.16.1",
"@mantine/form": "^7.16.1",
"@mantine/hooks": "^7.16.1",
+
"@radix-ui/react-dialog": "^1.1.5",
+
"@radix-ui/react-dropdown-menu": "^2.1.5",
+
"@radix-ui/react-label": "^2.1.1",
+
"@radix-ui/react-slot": "^1.1.1",
+
"@radix-ui/react-tabs": "^1.1.2",
+
"@radix-ui/react-toast": "^1.2.5",
+
"@tailwindcss/vite": "^4.0.0",
"axios": "^1.7.9",
+
"class-variance-authority": "^0.7.1",
+
"clsx": "^2.1.1",
+
"lucide-react": "^0.474.0",
"react": "^18.3.1",
-
"react-dom": "^18.3.1"
+
"react-dom": "^18.3.1",
+
"react-hook-form": "^7.54.2",
+
"recharts": "^2.15.0",
+
"tailwind-merge": "^2.6.0",
+
"tailwindcss-animate": "^1.0.7",
+
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
+
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.10.10",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
···
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
+
"postcss": "^8.5.1",
+
"tailwindcss": "^4.0.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5"
+5
frontend/postcss.config.js
···
+
export default {
+
plugins: {
+
'@tailwindcss/postcss': {},
+
},
+
}
-1
frontend/public/vite.svg
···
-
<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
···
-
#root {
-
max-width: 1280px;
-
margin: 0 auto;
-
padding: 2rem;
-
text-align: center;
-
}
-
-
.logo {
-
height: 6em;
-
padding: 1.5em;
-
will-change: filter;
-
transition: filter 300ms;
-
}
-
.logo:hover {
-
filter: drop-shadow(0 0 2em #646cffaa);
-
}
-
.logo.react:hover {
-
filter: drop-shadow(0 0 2em #61dafbaa);
-
}
-
-
@keyframes logo-spin {
-
from {
-
transform: rotate(0deg);
-
}
-
to {
-
transform: rotate(360deg);
-
}
-
}
-
-
@media (prefers-reduced-motion: no-preference) {
-
a:nth-of-type(2) .logo {
-
animation: logo-spin infinite 20s linear;
-
}
-
}
-
-
.card {
-
padding: 2em;
-
}
-
-
.read-the-docs {
-
color: #888;
-
}
+65 -21
frontend/src/App.tsx
···
-
import { MantineProvider, Container, Title, Stack } from '@mantine/core';
-
import { LinkForm } from './components/LinkForm';
-
import { LinkList } from './components/LinkList';
-
import { Link } from './types/api';
+
import { ThemeProvider } from "@/components/theme-provider"
+
import { LinkForm } from './components/LinkForm'
+
import { LinkList } from './components/LinkList'
+
import { AuthForms } from './components/AuthForms'
+
import { Footer } from './components/Footer'
+
import { AuthProvider, useAuth } from './context/AuthContext'
+
import { Button } from "@/components/ui/button"
+
import { Toaster } from './components/ui/toaster'
+
import { ModeToggle } from './components/mode-toggle'
+
import { useState } from 'react'
-
function App() {
-
const handleLinkCreated = (link: Link) => {
-
// You could update the list here or show a success message
-
window.location.reload();
-
};
+
function AppContent() {
+
const { user, logout } = useAuth()
+
const [refreshCounter, setRefreshCounter] = useState(0)
+
+
const handleLinkCreated = () => {
+
setRefreshCounter(prev => prev + 1)
+
}
-
return (
-
<MantineProvider withGlobalStyles withNormalizeCSS>
-
<Container size="lg" py="xl">
-
<Stack spacing="xl">
-
<Title order={1}>URL Shortener</Title>
-
<LinkForm onSuccess={handleLinkCreated} />
-
<LinkList />
-
</Stack>
-
</Container>
-
</MantineProvider>
-
);
+
return (
+
<div className="min-h-screen bg-background flex flex-col">
+
<header className="border-b">
+
<div className="container max-w-6xl mx-auto flex h-16 items-center justify-between px-4">
+
<h1 className="text-2xl font-bold">SimpleLink</h1>
+
<div className="flex items-center space-x-2 sm:space-x-4">
+
{user ? (
+
<>
+
<span className="text-sm text-muted-foreground hidden sm:inline">Welcome, {user.email}</span>
+
<Button variant="outline" size="sm" onClick={logout}>
+
Logout
+
</Button>
+
</>
+
) : (
+
<span className="text-sm text-muted-foreground mr-2">A link shortening service</span>
+
)}
+
<ModeToggle />
+
</div>
+
</div>
+
</header>
+
<main className="flex-1 flex flex-col">
+
<div className="container max-w-6xl mx-auto px-4 py-8 flex-1 flex flex-col">
+
<div className="space-y-8 flex-1 flex flex-col justify-center">
+
{user ? (
+
<>
+
<LinkForm onSuccess={handleLinkCreated} />
+
<LinkList refresh={refreshCounter} />
+
</>
+
) : (
+
<AuthForms />
+
)}
+
</div>
+
</div>
+
</main>
+
<Footer />
+
</div>
+
)
}
-
export default App;
+
function App() {
+
return (
+
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
+
<AuthProvider>
+
<AppContent />
+
<Toaster />
+
</AuthProvider>
+
</ThemeProvider>
+
)
+
}
+
export default App
+81 -1
frontend/src/api/client.ts
···
import axios from 'axios';
-
import { CreateLinkRequest, Link } from '../types/api';
+
import { CreateLinkRequest, Link, AuthResponse, ClickStats, SourceStats } from '../types/api';
+
// Create axios instance with default config
const api = axios.create({
baseURL: '/api',
});
+
// Add a request interceptor to add the auth token to all requests
+
api.interceptors.request.use((config) => {
+
const token = localStorage.getItem('token');
+
if (token) {
+
config.headers.Authorization = `Bearer ${token}`;
+
}
+
return config;
+
});
+
+
api.interceptors.response.use(
+
(response) => response,
+
(error) => {
+
if (error.response?.status === 401) {
+
localStorage.removeItem('token');
+
localStorage.removeItem('user');
+
+
window.dispatchEvent(new Event('unauthorized'));
+
}
+
return Promise.reject(error);
+
}
+
);
+
+
+
// Auth endpoints
+
export const login = async (email: string, password: string) => {
+
const response = await api.post<AuthResponse>('/auth/login', {
+
email,
+
password,
+
});
+
return response.data;
+
};
+
+
export const register = async (email: string, password: string, adminToken: string) => {
+
const response = await api.post<AuthResponse>('/auth/register', {
+
email,
+
password,
+
admin_token: adminToken,
+
});
+
return response.data;
+
};
+
+
// Protected endpoints
export const createShortLink = async (data: CreateLinkRequest) => {
const response = await api.post<Link>('/shorten', data);
return response.data;
···
return response.data;
};
+
export const editLink = async (id: number, data: Partial<CreateLinkRequest>) => {
+
const response = await api.patch<Link>(`/links/${id}`, data);
+
return response.data;
+
};
+
+
+
export const deleteLink = async (id: number) => {
+
await api.delete(`/links/${id}`);
+
};
+
+
export const getLinkClickStats = async (id: number) => {
+
try {
+
const response = await api.get<ClickStats[]>(`/links/${id}/clicks`);
+
return response.data;
+
} catch (error) {
+
console.error('Error fetching click stats:', error);
+
throw error;
+
}
+
};
+
+
export const getLinkSourceStats = async (id: number) => {
+
try {
+
const response = await api.get<SourceStats[]>(`/links/${id}/sources`);
+
return response.data;
+
} catch (error) {
+
console.error('Error fetching source stats:', error);
+
throw error;
+
}
+
};
+
+
+
export const checkFirstUser = async () => {
+
const response = await api.get<{ isFirstUser: boolean }>('/auth/check-first-user');
+
return response.data.isFirstUser;
+
};
+
+
export { api };
-1
frontend/src/assets/react.svg
···
-
<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
···
+
import { useState, useEffect } from 'react'
+
import { useForm } from 'react-hook-form'
+
import { z } from 'zod'
+
import { zodResolver } from '@hookform/resolvers/zod'
+
import { useAuth } from '../context/AuthContext'
+
import { Button } from '@/components/ui/button'
+
import { Input } from '@/components/ui/input'
+
import { Card } from '@/components/ui/card'
+
import {
+
Form,
+
FormControl,
+
FormField,
+
FormItem,
+
FormLabel,
+
FormMessage,
+
} from '@/components/ui/form'
+
import { useToast } from '@/hooks/use-toast'
+
import { checkFirstUser } from '../api/client'
+
+
const formSchema = z.object({
+
email: z.string().email('Invalid email address'),
+
password: z.string().min(6, 'Password must be at least 6 characters long'),
+
adminToken: z.string().optional(),
+
})
+
+
type FormValues = z.infer<typeof formSchema>
+
+
export function AuthForms() {
+
const [isFirstUser, setIsFirstUser] = useState<boolean | null>(null)
+
const { login, register } = useAuth()
+
const { toast } = useToast()
+
+
const form = useForm<FormValues>({
+
resolver: zodResolver(formSchema),
+
defaultValues: {
+
email: '',
+
password: '',
+
adminToken: '',
+
},
+
})
+
+
useEffect(() => {
+
const init = async () => {
+
try {
+
const isFirst = await checkFirstUser()
+
setIsFirstUser(isFirst)
+
} catch (err) {
+
console.error('Error checking first user:', err)
+
setIsFirstUser(false)
+
}
+
}
+
+
init()
+
}, [])
+
+
const onSubmit = async (values: FormValues) => {
+
try {
+
if (isFirstUser) {
+
await register(values.email, values.password, values.adminToken || '')
+
} else {
+
await login(values.email, values.password)
+
}
+
form.reset()
+
} catch (err: any) {
+
toast({
+
variant: 'destructive',
+
title: 'Error',
+
description: err.response?.data || 'An error occurred',
+
})
+
}
+
}
+
+
if (isFirstUser === null) {
+
return <div>Loading...</div>
+
}
+
+
return (
+
<Card className="w-full max-w-md mx-auto p-6">
+
<div className="mb-6 text-center">
+
<h2 className="text-2xl font-bold">
+
{isFirstUser ? 'Create Admin Account' : 'Login'}
+
</h2>
+
<p className="text-sm text-muted-foreground mt-1">
+
{isFirstUser
+
? 'Set up your admin account to get started'
+
: 'Welcome back! Please login to your account'}
+
</p>
+
</div>
+
+
<Form {...form}>
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+
<FormField
+
control={form.control}
+
name="email"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Email</FormLabel>
+
<FormControl>
+
<Input type="email" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
+
+
<FormField
+
control={form.control}
+
name="password"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Password</FormLabel>
+
<FormControl>
+
<Input type="password" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
+
+
{isFirstUser && (
+
<FormField
+
control={form.control}
+
name="adminToken"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Admin Setup Token</FormLabel>
+
<FormControl>
+
<Input type="text" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
+
)}
+
+
<Button type="submit" className="w-full">
+
{isFirstUser ? 'Create Account' : 'Sign in'}
+
</Button>
+
</form>
+
</Form>
+
</Card>
+
)
+
}
+139
frontend/src/components/EditModal.tsx
···
+
// src/components/EditModal.tsx
+
import { useState } from 'react';
+
import { useForm } from 'react-hook-form';
+
import { zodResolver } from '@hookform/resolvers/zod';
+
import * as z from 'zod';
+
import { Link } from '../types/api';
+
import { editLink } from '../api/client';
+
import { useToast } from '@/hooks/use-toast';
+
import {
+
Dialog,
+
DialogContent,
+
DialogHeader,
+
DialogTitle,
+
DialogFooter,
+
} from '@/components/ui/dialog';
+
import { Button } from '@/components/ui/button';
+
import { Input } from '@/components/ui/input';
+
import {
+
Form,
+
FormControl,
+
FormField,
+
FormItem,
+
FormLabel,
+
FormMessage,
+
} from '@/components/ui/form';
+
+
const formSchema = z.object({
+
url: z
+
.string()
+
.min(1, 'URL is required')
+
.url('Must be a valid URL')
+
.refine((val) => val.startsWith('http://') || val.startsWith('https://'), {
+
message: 'URL must start with http:// or https://',
+
}),
+
custom_code: z
+
.string()
+
.regex(/^[a-zA-Z0-9_-]{1,32}$/, {
+
message:
+
'Custom code must be 1-32 characters and contain only letters, numbers, underscores, and hyphens',
+
})
+
.optional(),
+
});
+
+
interface EditModalProps {
+
isOpen: boolean;
+
onClose: () => void;
+
link: Link;
+
onSuccess: () => void;
+
}
+
+
export function EditModal({ isOpen, onClose, link, onSuccess }: EditModalProps) {
+
const [loading, setLoading] = useState(false);
+
const { toast } = useToast();
+
+
const form = useForm<z.infer<typeof formSchema>>({
+
resolver: zodResolver(formSchema),
+
defaultValues: {
+
url: link.original_url,
+
custom_code: link.short_code,
+
},
+
});
+
+
const onSubmit = async (values: z.infer<typeof formSchema>) => {
+
try {
+
setLoading(true);
+
await editLink(link.id, values);
+
toast({
+
description: 'Link updated successfully',
+
});
+
onSuccess();
+
onClose();
+
} catch (err: unknown) {
+
const error = err as { response?: { data?: { error?: string } } };
+
toast({
+
variant: 'destructive',
+
title: 'Error',
+
description: error.response?.data?.error || 'Failed to update link',
+
});
+
} finally {
+
setLoading(false);
+
}
+
};
+
+
return (
+
<Dialog open={isOpen} onOpenChange={onClose}>
+
<DialogContent>
+
<DialogHeader>
+
<DialogTitle>Edit Link</DialogTitle>
+
</DialogHeader>
+
+
<Form {...form}>
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+
<FormField
+
control={form.control}
+
name="url"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Destination URL</FormLabel>
+
<FormControl>
+
<Input placeholder="https://example.com" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
+
+
<FormField
+
control={form.control}
+
name="custom_code"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Short Code</FormLabel>
+
<FormControl>
+
<Input placeholder="custom-code" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
+
+
<DialogFooter>
+
<Button
+
type="button"
+
variant="outline"
+
onClick={onClose}
+
disabled={loading}
+
>
+
Cancel
+
</Button>
+
<Button type="submit" disabled={loading}>
+
{loading ? 'Saving...' : 'Save Changes'}
+
</Button>
+
</DialogFooter>
+
</form>
+
</Form>
+
</DialogContent>
+
</Dialog>
+
);
+
}
+54
frontend/src/components/Footer.tsx
···
+
import { SiGithub, SiBluesky } from "@icons-pack/react-simple-icons"
+
import { Button } from "@/components/ui/button"
+
import { useState } from 'react'
+
import { PrivacyModal } from './PrivacyModal'
+
+
export function Footer() {
+
const [privacyModalOpen, setPrivacyModalOpen] = useState(false)
+
+
const handlePrivacyModalOpen = () => {
+
setPrivacyModalOpen(true)
+
}
+
+
const handlePrivacyModalClose = () => {
+
setPrivacyModalOpen(false)
+
}
+
+
return (
+
<footer className="border-t">
+
<div className="container max-w-6xl mx-auto flex h-14 items-center justify-between px-4">
+
<p className="text-sm text-muted-foreground">Created by waveringana</p>
+
<div className="flex items-center space-x-4">
+
<nav className="flex items-center space-x-4">
+
<a
+
onClick={handlePrivacyModalOpen}
+
href="#"
+
>
+
Privacy
+
</a>
+
</nav>
+
<div className="flex items-center space-x-2">
+
<Button variant="ghost" size="icon">
+
<a href="https://l.nekomimi.pet/github?source=shortener" target="_blank" rel="noopener noreferrer">
+
<SiGithub className="h-4 w-4" />
+
</a>
+
<span className="sr-only">GitHub</span>
+
</Button>
+
+
<Button variant="ghost" size="icon">
+
<a href="https://l.nekomimi.pet/bsky?source=shortener" target="_blank" rel="noopener noreferrer">
+
<SiBluesky className="h-4 w-4" />
+
</a>
+
<span className="sr-only">Twitter</span>
+
</Button>
+
</div>
+
</div>
+
</div>
+
+
<PrivacyModal
+
isOpen={privacyModalOpen}
+
onClose={handlePrivacyModalClose}
+
/>
+
</footer>
+
)
+
}
+99 -66
frontend/src/components/LinkForm.tsx
···
-
import { useState } from 'react';
-
import { TextInput, Button, Group, Box, Text } from '@mantine/core';
-
import { useForm } from '@mantine/form';
-
import { CreateLinkRequest, Link } from '../types/api';
-
import { createShortLink } from '../api/client';
+
import { useState } from 'react'
+
import { useForm } from 'react-hook-form'
+
import { zodResolver } from '@hookform/resolvers/zod'
+
import * as z from 'zod'
+
import { CreateLinkRequest } from '../types/api'
+
import { createShortLink } from '../api/client'
+
import { Button } from "@/components/ui/button"
+
import { Input } from "@/components/ui/input"
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+
import { LinkIcon } from "lucide-react"
+
import {
+
Form,
+
FormControl,
+
FormField,
+
FormLabel,
+
FormMessage,
+
} from "@/components/ui/form"
+
import { useToast } from "@/hooks/use-toast"
+
+
const formSchema = z.object({
+
url: z.string()
+
.min(1, 'URL is required')
+
.url('Must be a valid URL')
+
.refine(val => val.startsWith('http://') || val.startsWith('https://'), {
+
message: 'URL must start with http:// or https://'
+
}),
+
custom_code: z.string()
+
.regex(/^[a-zA-Z0-9_-]{0,32}$/, 'Custom code must contain only letters, numbers, underscores, and hyphens')
+
.optional()
+
})
interface LinkFormProps {
-
onSuccess: (link: Link) => void;
+
onSuccess: () => void;
}
export function LinkForm({ onSuccess }: LinkFormProps) {
-
const [error, setError] = useState<string | null>(null);
-
const [loading, setLoading] = useState(false);
+
const [loading, setLoading] = useState(false)
+
const { toast } = useToast()
-
const form = useForm<CreateLinkRequest>({
-
initialValues: {
+
const form = useForm<z.infer<typeof formSchema>>({
+
resolver: zodResolver(formSchema),
+
defaultValues: {
url: '',
custom_code: '',
},
-
validate: {
-
url: (value) => {
-
if (!value) return 'URL is required';
-
if (!value.startsWith('http://') && !value.startsWith('https://')) {
-
return 'URL must start with http:// or https://';
-
}
-
return null;
-
},
-
custom_code: (value) => {
-
if (value && !/^[a-zA-Z0-9_-]{1,32}$/.test(value)) {
-
return 'Custom code must be 1-32 characters and contain only letters, numbers, underscores, and hyphens';
-
}
-
return null;
-
},
-
},
-
});
+
})
-
const handleSubmit = async (values: CreateLinkRequest) => {
+
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
-
setLoading(true);
-
setError(null);
-
const link = await createShortLink(values);
-
form.reset();
-
onSuccess(link);
-
} catch (err) {
-
setError(err.response?.data?.error || 'An error occurred');
+
setLoading(true)
+
await createShortLink(values as CreateLinkRequest)
+
form.reset()
+
onSuccess() // Call the onSuccess callback to trigger refresh
+
toast({
+
description: "Short link created successfully",
+
})
+
} catch (err: any) {
+
toast({
+
variant: "destructive",
+
title: "Error",
+
description: err.response?.data?.error || 'An error occurred',
+
})
} finally {
-
setLoading(false);
+
setLoading(false)
}
-
};
+
}
return (
-
<Box mx="auto" sx={{ maxWidth: 500 }}>
-
<form onSubmit={form.onSubmit(handleSubmit)}>
-
<TextInput
-
required
-
label="URL"
-
placeholder="https://example.com"
-
{...form.getInputProps('url')}
-
/>
-
-
<TextInput
-
label="Custom Code (optional)"
-
placeholder="example"
-
mt="md"
-
{...form.getInputProps('custom_code')}
-
/>
-
-
{error && (
-
<Text color="red" size="sm" mt="sm">
-
{error}
-
</Text>
-
)}
+
<Card className="mb-8">
+
<CardHeader>
+
<CardTitle>Create Short Link</CardTitle>
+
<CardDescription>Enter a URL to generate a shortened link</CardDescription>
+
</CardHeader>
+
<CardContent>
+
<Form {...form}>
+
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4 md:flex-row md:items-end">
+
<FormField
+
control={form.control}
+
name="url"
+
render={({ field }) => (
+
<div className="flex-1 space-y-2">
+
<FormLabel>URL</FormLabel>
+
<FormControl>
+
<div className="relative">
+
<LinkIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+
<Input placeholder="https://example.com" className="pl-9" {...field} />
+
</div>
+
</FormControl>
+
<FormMessage />
+
</div>
+
)}
+
/>
-
<Group position="right" mt="md">
-
<Button type="submit" loading={loading}>
-
Create Short Link
-
</Button>
-
</Group>
-
</form>
-
</Box>
-
);
-
}
+
<FormField
+
control={form.control}
+
name="custom_code"
+
render={({ field }) => (
+
<div className="w-full md:w-1/4 space-y-2">
+
<FormLabel>Custom Code <span className="text-muted-foreground">(optional)</span></FormLabel>
+
<FormControl>
+
<Input placeholder="custom-code" {...field} />
+
</FormControl>
+
<FormMessage />
+
</div>
+
)}
+
/>
+
<Button type="submit" disabled={loading} className="md:w-auto">
+
{loading ? "Creating..." : "Create Short Link"}
+
</Button>
+
</form>
+
</Form>
+
</CardContent>
+
</Card>
+
)
+
}
+210 -58
frontend/src/components/LinkList.tsx
···
-
import { useEffect, useState } from 'react';
-
import { Table, Text, Box, CopyButton, Button } from '@mantine/core';
-
import { Link } from '../types/api';
-
import { getAllLinks } from '../api/client';
+
import { useCallback, useEffect, useState } from 'react'
+
import { Link } from '../types/api'
+
import { getAllLinks, deleteLink } from '../api/client'
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+
import {
+
Table,
+
TableBody,
+
TableCell,
+
TableHead,
+
TableHeader,
+
TableRow,
+
} from "@/components/ui/table"
+
import { Button } from "@/components/ui/button"
+
import { useToast } from "@/hooks/use-toast"
+
import { Copy, Trash2, BarChart2, Pencil } from "lucide-react"
+
import {
+
Dialog,
+
DialogContent,
+
DialogHeader,
+
DialogTitle,
+
DialogDescription,
+
DialogFooter,
+
} from "@/components/ui/dialog"
+
+
import { StatisticsModal } from "./StatisticsModal"
+
import { EditModal } from './EditModal'
+
+
interface LinkListProps {
+
refresh?: number;
+
}
-
export function LinkList() {
-
const [links, setLinks] = useState<Link[]>([]);
-
const [loading, setLoading] = useState(true);
-
const [error, setError] = useState<string | null>(null);
+
export function LinkList({ refresh = 0 }: LinkListProps) {
+
const [links, setLinks] = useState<Link[]>([])
+
const [loading, setLoading] = useState(true)
+
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; linkId: number | null }>({
+
isOpen: false,
+
linkId: null,
+
})
+
const [statsModal, setStatsModal] = useState<{ isOpen: boolean; linkId: number | null }>({
+
isOpen: false,
+
linkId: null,
+
});
+
const [editModal, setEditModal] = useState<{ isOpen: boolean; link: Link | null }>({
+
isOpen: false,
+
link: null,
+
});
+
const { toast } = useToast()
-
const fetchLinks = async () => {
+
const fetchLinks = useCallback(async () => {
try {
-
setLoading(true);
-
const data = await getAllLinks();
-
setLinks(data);
-
} catch (err) {
-
setError('Failed to load links');
+
setLoading(true)
+
const data = await getAllLinks()
+
setLinks(data)
+
} catch (err: unknown) {
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
+
toast({
+
title: "Error",
+
description: `Failed to load links: ${errorMessage}`,
+
variant: "destructive",
+
})
} finally {
-
setLoading(false);
+
setLoading(false)
}
-
};
+
}, [toast, setLinks, setLoading])
useEffect(() => {
-
fetchLinks();
-
}, []);
+
fetchLinks()
+
}, [fetchLinks, refresh]) // Re-fetch when refresh counter changes
+
+
const handleDelete = async () => {
+
if (!deleteModal.linkId) return
+
+
try {
+
await deleteLink(deleteModal.linkId)
+
await fetchLinks()
+
setDeleteModal({ isOpen: false, linkId: null })
+
toast({
+
description: "Link deleted successfully",
+
})
+
} catch (err: unknown) {
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
+
toast({
+
title: "Error",
+
description: `Failed to delete link: ${errorMessage}`,
+
variant: "destructive",
+
})
+
}
+
}
+
+
const handleCopy = (shortCode: string) => {
+
// Use import.meta.env.VITE_BASE_URL or fall back to window.location.origin
+
const baseUrl = window.location.origin
+
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
+
toast({
+
description: (
+
<>
+
Link copied to clipboard
+
<br />
+
You can add ?source=TextHere to the end of the link to track the source of clicks
+
</>
+
),
+
})
+
}
-
if (loading) return <Text>Loading...</Text>;
-
if (error) return <Text color="red">{error}</Text>;
+
if (loading && !links.length) {
+
return <div className="text-center py-4">Loading...</div>
+
}
return (
-
<Box>
-
<Table>
-
<thead>
-
<tr>
-
<th>Short Code</th>
-
<th>Original URL</th>
-
<th>Clicks</th>
-
<th>Created</th>
-
<th>Actions</th>
-
</tr>
-
</thead>
-
<tbody>
-
{links.map((link) => (
-
<tr key={link.id}>
-
<td>{link.short_code}</td>
-
<td>{link.original_url}</td>
-
<td>{link.clicks}</td>
-
<td>{new Date(link.created_at).toLocaleDateString()}</td>
-
<td>
-
<CopyButton value={`${window.location.origin}/${link.short_code}`}>
-
{({ copied, copy }) => (
-
<Button
-
color={copied ? 'teal' : 'blue'}
-
onClick={copy}
-
size="xs"
-
>
-
{copied ? 'Copied' : 'Copy'}
-
</Button>
-
)}
-
</CopyButton>
-
</td>
-
</tr>
-
))}
-
</tbody>
-
</Table>
-
</Box>
-
);
-
}
+
<>
+
<Dialog open={deleteModal.isOpen} onOpenChange={(open) => setDeleteModal({ isOpen: open, linkId: null })}>
+
<DialogContent>
+
<DialogHeader>
+
<DialogTitle>Delete Link</DialogTitle>
+
<DialogDescription>
+
Are you sure you want to delete this link? This action cannot be undone.
+
</DialogDescription>
+
</DialogHeader>
+
<DialogFooter>
+
<Button variant="outline" onClick={() => setDeleteModal({ isOpen: false, linkId: null })}>
+
Cancel
+
</Button>
+
<Button variant="destructive" onClick={handleDelete}>
+
Delete
+
</Button>
+
</DialogFooter>
+
</DialogContent>
+
</Dialog>
+
+
<Card>
+
<CardHeader>
+
<CardTitle>Your Links</CardTitle>
+
<CardDescription>Manage and track your shortened links</CardDescription>
+
</CardHeader>
+
<CardContent>
+
<div className="rounded-md border">
+
<Table>
+
<TableHeader>
+
<TableRow>
+
<TableHead>Short Code</TableHead>
+
<TableHead className="hidden md:table-cell">Original URL</TableHead>
+
<TableHead>Clicks</TableHead>
+
<TableHead className="hidden md:table-cell">Created</TableHead>
+
<TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead>
+
</TableRow>
+
</TableHeader>
+
<TableBody>
+
{links.map((link) => (
+
<TableRow key={link.id}>
+
<TableCell className="font-medium">{link.short_code}</TableCell>
+
<TableCell className="hidden md:table-cell max-w-[300px] truncate">
+
{link.original_url}
+
</TableCell>
+
<TableCell>{link.clicks}</TableCell>
+
<TableCell className="hidden md:table-cell">
+
{new Date(link.created_at).toLocaleDateString()}
+
</TableCell>
+
<TableCell className="p-2 pr-4">
+
<div className="flex items-center gap-1">
+
<Button
+
variant="ghost"
+
size="icon"
+
className="h-8 w-8"
+
onClick={() => handleCopy(link.short_code)}
+
>
+
<Copy className="h-4 w-4" />
+
<span className="sr-only">Copy link</span>
+
</Button>
+
<Button
+
variant="ghost"
+
size="icon"
+
className="h-8 w-8"
+
onClick={() => setStatsModal({ isOpen: true, linkId: link.id })}
+
>
+
<BarChart2 className="h-4 w-4" />
+
<span className="sr-only">View statistics</span>
+
</Button>
+
<Button
+
variant="ghost"
+
size="icon"
+
className="h-8 w-8"
+
onClick={() => setEditModal({ isOpen: true, link })}
+
>
+
<Pencil className="h-4 w-4" />
+
<span className="sr-only">Edit Link</span>
+
</Button>
+
<Button
+
variant="ghost"
+
size="icon"
+
className="h-8 w-8 text-destructive"
+
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
+
>
+
<Trash2 className="h-4 w-4" />
+
<span className="sr-only">Delete link</span>
+
</Button>
+
</div>
+
</TableCell>
+
</TableRow>
+
))}
+
</TableBody>
+
</Table>
+
</div>
+
</CardContent>
+
</Card>
+
<StatisticsModal
+
isOpen={statsModal.isOpen}
+
onClose={() => setStatsModal({ isOpen: false, linkId: null })}
+
linkId={statsModal.linkId!}
+
/>
+
{editModal.link && (
+
<EditModal
+
isOpen={editModal.isOpen}
+
onClose={() => setEditModal({ isOpen: false, link: null })}
+
link={editModal.link}
+
onSuccess={fetchLinks}
+
/>
+
)}
+
</>
+
)
+
}
+39
frontend/src/components/PrivacyModal.tsx
···
+
import {
+
Dialog,
+
DialogContent,
+
DialogDescription,
+
DialogFooter,
+
DialogHeader,
+
DialogTitle,
+
} from "@/components/ui/dialog"
+
+
import { Button } from "@/components/ui/button"
+
+
interface PrivacyModalProps {
+
isOpen: boolean;
+
onClose: () => void;
+
}
+
+
export function PrivacyModal({ isOpen, onClose }: PrivacyModalProps) {
+
return (
+
<Dialog open={isOpen}>
+
<DialogContent className="max-w-md">
+
<DialogHeader>
+
<DialogTitle>Privacy Policy</DialogTitle>
+
<DialogDescription>
+
Simplelink's data collection and usage policies
+
</DialogDescription>
+
</DialogHeader>
+
<div className="text-sm text-muted-foreground">
+
<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>
+
</div>
+
<DialogFooter>
+
<Button variant="outline" onClick={onClose}>
+
Close
+
</Button>
+
</DialogFooter>
+
</DialogContent>
+
</Dialog>
+
)
+
}
+
+177
frontend/src/components/StatisticsModal.tsx
···
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+
import {
+
LineChart,
+
Line,
+
XAxis,
+
YAxis,
+
CartesianGrid,
+
Tooltip,
+
ResponsiveContainer,
+
} from "recharts";
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+
import { toast } from "@/hooks/use-toast";
+
import { useState, useEffect, useMemo } from "react";
+
+
import { getLinkClickStats, getLinkSourceStats } from "../api/client";
+
import { ClickStats, SourceStats } from "../types/api";
+
+
interface StatisticsModalProps {
+
isOpen: boolean;
+
onClose: () => void;
+
linkId: number;
+
}
+
+
interface EnhancedClickStats extends ClickStats {
+
sources?: { source: string; count: number }[];
+
}
+
+
const CustomTooltip = ({
+
active,
+
payload,
+
label,
+
}: {
+
active?: boolean;
+
payload?: { value: number; payload: EnhancedClickStats }[];
+
label?: string;
+
}) => {
+
if (active && payload && payload.length > 0) {
+
const data = payload[0].payload;
+
return (
+
<div className="bg-background text-foreground p-4 rounded-lg shadow-lg border">
+
<p className="font-medium">{label}</p>
+
<p className="text-sm">Clicks: {data.clicks}</p>
+
{data.sources && data.sources.length > 0 && (
+
<div className="mt-2">
+
<p className="font-medium text-sm">Sources:</p>
+
<ul className="text-sm">
+
{data.sources.map((source: { source: string; count: number }) => (
+
<li key={source.source}>
+
{source.source}: {source.count}
+
</li>
+
))}
+
</ul>
+
</div>
+
)}
+
</div>
+
);
+
}
+
return null;
+
};
+
+
export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) {
+
const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]);
+
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
+
const [loading, setLoading] = useState(true);
+
+
useEffect(() => {
+
if (isOpen && linkId) {
+
const fetchData = async () => {
+
try {
+
setLoading(true);
+
const [clicksData, sourcesData] = await Promise.all([
+
getLinkClickStats(linkId),
+
getLinkSourceStats(linkId),
+
]);
+
+
// Enhance clicks data with source information
+
const enhancedClicksData = clicksData.map((clickData) => ({
+
...clickData,
+
sources: sourcesData.filter((source) => source.date === clickData.date),
+
}));
+
+
setClicksOverTime(enhancedClicksData);
+
setSourcesData(sourcesData);
+
} catch (error: unknown) {
+
console.error("Failed to fetch statistics:", error);
+
toast({
+
variant: "destructive",
+
title: "Error",
+
description: error instanceof Error ? error.message : "Failed to load statistics",
+
});
+
} finally {
+
setLoading(false);
+
}
+
};
+
+
fetchData();
+
}
+
}, [isOpen, linkId]);
+
+
const aggregatedSources = useMemo(() => {
+
const sourceMap = sourcesData.reduce<Record<string, number>>(
+
(acc, { source, count }) => ({
+
...acc,
+
[source]: (acc[source] || 0) + count
+
}),
+
{}
+
);
+
+
return Object.entries(sourceMap)
+
.map(([source, count]) => ({ source, count }))
+
.sort((a, b) => b.count - a.count);
+
}, [sourcesData]);
+
+
return (
+
<Dialog open={isOpen} onOpenChange={onClose}>
+
<DialogContent className="max-w-3xl">
+
<DialogHeader>
+
<DialogTitle>Link Statistics</DialogTitle>
+
</DialogHeader>
+
+
{loading ? (
+
<div className="flex items-center justify-center h-64">Loading...</div>
+
) : (
+
<div className="grid gap-4">
+
<Card>
+
<CardHeader>
+
<CardTitle>Clicks Over Time</CardTitle>
+
</CardHeader>
+
<CardContent>
+
<div className="h-[300px]">
+
<ResponsiveContainer width="100%" height="100%">
+
<LineChart data={clicksOverTime}>
+
<CartesianGrid strokeDasharray="3 3" />
+
<XAxis dataKey="date" />
+
<YAxis />
+
<Tooltip content={<CustomTooltip />} />
+
<Line
+
type="monotone"
+
dataKey="clicks"
+
stroke="#8884d8"
+
strokeWidth={2}
+
/>
+
</LineChart>
+
</ResponsiveContainer>
+
</div>
+
</CardContent>
+
</Card>
+
+
<Card>
+
<CardHeader>
+
<CardTitle>Top Sources</CardTitle>
+
</CardHeader>
+
<CardContent>
+
<ul className="space-y-2">
+
{aggregatedSources.map((source, index) => (
+
<li
+
key={source.source}
+
className="flex items-center justify-between py-2 border-b last:border-0"
+
>
+
<span className="text-sm">
+
<span className="font-medium text-muted-foreground mr-2">
+
{index + 1}.
+
</span>
+
{source.source}
+
</span>
+
<span className="text-sm font-medium">{source.count} clicks</span>
+
</li>
+
))}
+
</ul>
+
</CardContent>
+
</Card>
+
</div>
+
)}
+
</DialogContent>
+
</Dialog>
+
);
+
}
+37
frontend/src/components/mode-toggle.tsx
···
+
import { Moon, Sun } from "lucide-react"
+
+
import { Button } from "@/components/ui/button"
+
import {
+
DropdownMenu,
+
DropdownMenuContent,
+
DropdownMenuItem,
+
DropdownMenuTrigger,
+
} from "@/components/ui/dropdown-menu"
+
import { useTheme } from "@/components/theme-provider"
+
+
export function ModeToggle() {
+
const { setTheme } = useTheme()
+
+
return (
+
<DropdownMenu>
+
<DropdownMenuTrigger asChild>
+
<Button variant="outline" size="icon">
+
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
+
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
+
<span className="sr-only">Toggle theme</span>
+
</Button>
+
</DropdownMenuTrigger>
+
<DropdownMenuContent align="end">
+
<DropdownMenuItem onClick={() => setTheme("light")}>
+
Light
+
</DropdownMenuItem>
+
<DropdownMenuItem onClick={() => setTheme("dark")}>
+
Dark
+
</DropdownMenuItem>
+
<DropdownMenuItem onClick={() => setTheme("system")}>
+
System
+
</DropdownMenuItem>
+
</DropdownMenuContent>
+
</DropdownMenu>
+
)
+
}
+73
frontend/src/components/theme-provider.tsx
···
+
import { createContext, useContext, useEffect, useState } from "react"
+
+
type Theme = "dark" | "light" | "system"
+
+
type ThemeProviderProps = {
+
children: React.ReactNode
+
defaultTheme?: Theme
+
storageKey?: string
+
}
+
+
type ThemeProviderState = {
+
theme: Theme
+
setTheme: (theme: Theme) => void
+
}
+
+
const initialState: ThemeProviderState = {
+
theme: "system",
+
setTheme: () => null,
+
}
+
+
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
+
+
export function ThemeProvider({
+
children,
+
defaultTheme = "system",
+
storageKey = "vite-ui-theme",
+
...props
+
}: ThemeProviderProps) {
+
const [theme, setTheme] = useState<Theme>(
+
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
+
)
+
+
useEffect(() => {
+
const root = window.document.documentElement
+
+
root.classList.remove("light", "dark")
+
+
if (theme === "system") {
+
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
+
.matches
+
? "dark"
+
: "light"
+
+
root.classList.add(systemTheme)
+
return
+
}
+
+
root.classList.add(theme)
+
}, [theme])
+
+
const value = {
+
theme,
+
setTheme: (theme: Theme) => {
+
localStorage.setItem(storageKey, theme)
+
setTheme(theme)
+
},
+
}
+
+
return (
+
<ThemeProviderContext.Provider {...props} value={value}>
+
{children}
+
</ThemeProviderContext.Provider>
+
)
+
}
+
+
export const useTheme = () => {
+
const context = useContext(ThemeProviderContext)
+
+
if (context === undefined)
+
throw new Error("useTheme must be used within a ThemeProvider")
+
+
return context
+
}
+57
frontend/src/components/ui/button.tsx
···
+
import * as React from "react"
+
import { Slot } from "@radix-ui/react-slot"
+
import { cva, type VariantProps } from "class-variance-authority"
+
+
import { cn } from "@/lib/utils"
+
+
const buttonVariants = cva(
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+
{
+
variants: {
+
variant: {
+
default:
+
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
+
destructive:
+
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
+
outline:
+
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
+
secondary:
+
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+
ghost: "hover:bg-accent hover:text-accent-foreground",
+
link: "text-primary underline-offset-4 hover:underline",
+
},
+
size: {
+
default: "h-9 px-4 py-2",
+
sm: "h-8 rounded-md px-3 text-xs",
+
lg: "h-10 rounded-md px-8",
+
icon: "h-9 w-9",
+
},
+
},
+
defaultVariants: {
+
variant: "default",
+
size: "default",
+
},
+
}
+
)
+
+
export interface ButtonProps
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+
VariantProps<typeof buttonVariants> {
+
asChild?: boolean
+
}
+
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+
({ className, variant, size, asChild = false, ...props }, ref) => {
+
const Comp = asChild ? Slot : "button"
+
return (
+
<Comp
+
className={cn(buttonVariants({ variant, size, className }))}
+
ref={ref}
+
{...props}
+
/>
+
)
+
}
+
)
+
Button.displayName = "Button"
+
+
export { Button, buttonVariants }
+76
frontend/src/components/ui/card.tsx
···
+
import * as React from "react"
+
+
import { cn } from "@/lib/utils"
+
+
const Card = React.forwardRef<
+
HTMLDivElement,
+
React.HTMLAttributes<HTMLDivElement>
+
>(({ className, ...props }, ref) => (
+
<div
+
ref={ref}
+
className={cn(
+
"rounded-xl border bg-card text-card-foreground shadow-sm",
+
className
+
)}
+
{...props}
+
/>
+
))
+
Card.displayName = "Card"
+
+
const CardHeader = React.forwardRef<
+
HTMLDivElement,
+
React.HTMLAttributes<HTMLDivElement>
+
>(({ className, ...props }, ref) => (
+
<div
+
ref={ref}
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
+
{...props}
+
/>
+
))
+
CardHeader.displayName = "CardHeader"
+
+
const CardTitle = React.forwardRef<
+
HTMLDivElement,
+
React.HTMLAttributes<HTMLDivElement>
+
>(({ className, ...props }, ref) => (
+
<div
+
ref={ref}
+
className={cn("font-semibold leading-none tracking-tight", className)}
+
{...props}
+
/>
+
))
+
CardTitle.displayName = "CardTitle"
+
+
const CardDescription = React.forwardRef<
+
HTMLDivElement,
+
React.HTMLAttributes<HTMLDivElement>
+
>(({ className, ...props }, ref) => (
+
<div
+
ref={ref}
+
className={cn("text-sm text-muted-foreground", className)}
+
{...props}
+
/>
+
))
+
CardDescription.displayName = "CardDescription"
+
+
const CardContent = React.forwardRef<
+
HTMLDivElement,
+
React.HTMLAttributes<HTMLDivElement>
+
>(({ className, ...props }, ref) => (
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
+
))
+
CardContent.displayName = "CardContent"
+
+
const CardFooter = React.forwardRef<
+
HTMLDivElement,
+
React.HTMLAttributes<HTMLDivElement>
+
>(({ className, ...props }, ref) => (
+
<div
+
ref={ref}
+
className={cn("flex items-center p-6 pt-0", className)}
+
{...props}
+
/>
+
))
+
CardFooter.displayName = "CardFooter"
+
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+13
frontend/src/components/ui/container.tsx
···
+
import { cn } from "@/lib/utils"
+
+
export function Container({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+
return (
+
<div
+
className={cn(
+
"mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8",
+
className
+
)}
+
{...props}
+
/>
+
)
+
}
+120
frontend/src/components/ui/dialog.tsx
···
+
import * as React from "react"
+
import * as DialogPrimitive from "@radix-ui/react-dialog"
+
import { X } from "lucide-react"
+
+
import { cn } from "@/lib/utils"
+
+
const Dialog = DialogPrimitive.Root
+
+
const DialogTrigger = DialogPrimitive.Trigger
+
+
const DialogPortal = DialogPrimitive.Portal
+
+
const DialogClose = DialogPrimitive.Close
+
+
const DialogOverlay = React.forwardRef<
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+
>(({ className, ...props }, ref) => (
+
<DialogPrimitive.Overlay
+
ref={ref}
+
className={cn(
+
"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",
+
className
+
)}
+
{...props}
+
/>
+
))
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+
const DialogContent = React.forwardRef<
+
React.ElementRef<typeof DialogPrimitive.Content>,
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+
>(({ className, children, ...props }, ref) => (
+
<DialogPortal>
+
<DialogOverlay />
+
<DialogPrimitive.Content
+
ref={ref}
+
className={cn(
+
"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",
+
className
+
)}
+
{...props}
+
>
+
{children}
+
<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">
+
<X className="h-4 w-4" />
+
<span className="sr-only">Close</span>
+
</DialogPrimitive.Close>
+
</DialogPrimitive.Content>
+
</DialogPortal>
+
))
+
DialogContent.displayName = DialogPrimitive.Content.displayName
+
+
const DialogHeader = ({
+
className,
+
...props
+
}: React.HTMLAttributes<HTMLDivElement>) => (
+
<div
+
className={cn(
+
"flex flex-col space-y-1.5 text-center sm:text-left",
+
className
+
)}
+
{...props}
+
/>
+
)
+
DialogHeader.displayName = "DialogHeader"
+
+
const DialogFooter = ({
+
className,
+
...props
+
}: React.HTMLAttributes<HTMLDivElement>) => (
+
<div
+
className={cn(
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+
className
+
)}
+
{...props}
+
/>
+
)
+
DialogFooter.displayName = "DialogFooter"
+
+
const DialogTitle = React.forwardRef<
+
React.ElementRef<typeof DialogPrimitive.Title>,
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+
>(({ className, ...props }, ref) => (
+
<DialogPrimitive.Title
+
ref={ref}
+
className={cn(
+
"text-lg font-semibold leading-none tracking-tight",
+
className
+
)}
+
{...props}
+
/>
+
))
+
DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+
const DialogDescription = React.forwardRef<
+
React.ElementRef<typeof DialogPrimitive.Description>,
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+
>(({ className, ...props }, ref) => (
+
<DialogPrimitive.Description
+
ref={ref}
+
className={cn("text-sm text-muted-foreground", className)}
+
{...props}
+
/>
+
))
+
DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+
export {
+
Dialog,
+
DialogPortal,
+
DialogOverlay,
+
DialogTrigger,
+
DialogClose,
+
DialogContent,
+
DialogHeader,
+
DialogFooter,
+
DialogTitle,
+
DialogDescription,
+
}
+199
frontend/src/components/ui/dropdown-menu.tsx
···
+
import * as React from "react"
+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+
import { Check, ChevronRight, Circle } from "lucide-react"
+
+
import { cn } from "@/lib/utils"
+
+
const DropdownMenu = DropdownMenuPrimitive.Root
+
+
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+
const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+
const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+
const DropdownMenuSubTrigger = React.forwardRef<
+
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
+
inset?: boolean
+
}
+
>(({ className, inset, children, ...props }, ref) => (
+
<DropdownMenuPrimitive.SubTrigger
+
ref={ref}
+
className={cn(
+
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+
inset && "pl-8",
+
className
+
)}
+
{...props}
+
>
+
{children}
+
<ChevronRight className="ml-auto" />
+
</DropdownMenuPrimitive.SubTrigger>
+
))
+
DropdownMenuSubTrigger.displayName =
+
DropdownMenuPrimitive.SubTrigger.displayName
+
+
const DropdownMenuSubContent = React.forwardRef<
+
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
+
>(({ className, ...props }, ref) => (
+
<DropdownMenuPrimitive.SubContent
+
ref={ref}
+
className={cn(
+
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+
className
+
)}
+
{...props}
+
/>
+
))
+
DropdownMenuSubContent.displayName =
+
DropdownMenuPrimitive.SubContent.displayName
+
+
const DropdownMenuContent = React.forwardRef<
+
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
+
>(({ className, sideOffset = 4, ...props }, ref) => (
+
<DropdownMenuPrimitive.Portal>
+
<DropdownMenuPrimitive.Content
+
ref={ref}
+
sideOffset={sideOffset}
+
className={cn(
+
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+
className
+
)}
+
{...props}
+
/>
+
</DropdownMenuPrimitive.Portal>
+
))
+
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+
const DropdownMenuItem = React.forwardRef<
+
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
+
inset?: boolean
+
}
+
>(({ className, inset, ...props }, ref) => (
+
<DropdownMenuPrimitive.Item
+
ref={ref}
+
className={cn(
+
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+
inset && "pl-8",
+
className
+
)}
+
{...props}
+
/>
+
))
+
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+
const DropdownMenuCheckboxItem = React.forwardRef<
+
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
+
>(({ className, children, checked, ...props }, ref) => (
+
<DropdownMenuPrimitive.CheckboxItem
+
ref={ref}
+
className={cn(
+
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+
className
+
)}
+
checked={checked}
+
{...props}
+
>
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+
<DropdownMenuPrimitive.ItemIndicator>
+
<Check className="h-4 w-4" />
+
</DropdownMenuPrimitive.ItemIndicator>
+
</span>
+
{children}
+
</DropdownMenuPrimitive.CheckboxItem>
+
))
+
DropdownMenuCheckboxItem.displayName =
+
DropdownMenuPrimitive.CheckboxItem.displayName
+
+
const DropdownMenuRadioItem = React.forwardRef<
+
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
+
>(({ className, children, ...props }, ref) => (
+
<DropdownMenuPrimitive.RadioItem
+
ref={ref}
+
className={cn(
+
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+
className
+
)}
+
{...props}
+
>
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+
<DropdownMenuPrimitive.ItemIndicator>
+
<Circle className="h-2 w-2 fill-current" />
+
</DropdownMenuPrimitive.ItemIndicator>
+
</span>
+
{children}
+
</DropdownMenuPrimitive.RadioItem>
+
))
+
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+
const DropdownMenuLabel = React.forwardRef<
+
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
+
inset?: boolean
+
}
+
>(({ className, inset, ...props }, ref) => (
+
<DropdownMenuPrimitive.Label
+
ref={ref}
+
className={cn(
+
"px-2 py-1.5 text-sm font-semibold",
+
inset && "pl-8",
+
className
+
)}
+
{...props}
+
/>
+
))
+
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+
const DropdownMenuSeparator = React.forwardRef<
+
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
+
>(({ className, ...props }, ref) => (
+
<DropdownMenuPrimitive.Separator
+
ref={ref}
+
className={cn("-mx-1 my-1 h-px bg-muted", className)}
+
{...props}
+
/>
+
))
+
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+
const DropdownMenuShortcut = ({
+
className,
+
...props
+
}: React.HTMLAttributes<HTMLSpanElement>) => {
+
return (
+
<span
+
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
+
{...props}
+
/>
+
)
+
}
+
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+
export {
+
DropdownMenu,
+
DropdownMenuTrigger,
+
DropdownMenuContent,
+
DropdownMenuItem,
+
DropdownMenuCheckboxItem,
+
DropdownMenuRadioItem,
+
DropdownMenuLabel,
+
DropdownMenuSeparator,
+
DropdownMenuShortcut,
+
DropdownMenuGroup,
+
DropdownMenuPortal,
+
DropdownMenuSub,
+
DropdownMenuSubContent,
+
DropdownMenuSubTrigger,
+
DropdownMenuRadioGroup,
+
}
+178
frontend/src/components/ui/form.tsx
···
+
"use client"
+
+
import * as React from "react"
+
import * as LabelPrimitive from "@radix-ui/react-label"
+
import { Slot } from "@radix-ui/react-slot"
+
import {
+
Controller,
+
ControllerProps,
+
FieldPath,
+
FieldValues,
+
FormProvider,
+
useFormContext,
+
} from "react-hook-form"
+
+
import { cn } from "@/lib/utils"
+
import { Label } from "@/components/ui/label"
+
+
const Form = FormProvider
+
+
type FormFieldContextValue<
+
TFieldValues extends FieldValues = FieldValues,
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
+
> = {
+
name: TName
+
}
+
+
const FormFieldContext = React.createContext<FormFieldContextValue>(
+
{} as FormFieldContextValue
+
)
+
+
const FormField = <
+
TFieldValues extends FieldValues = FieldValues,
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
+
>({
+
...props
+
}: ControllerProps<TFieldValues, TName>) => {
+
return (
+
<FormFieldContext.Provider value={{ name: props.name }}>
+
<Controller {...props} />
+
</FormFieldContext.Provider>
+
)
+
}
+
+
const useFormField = () => {
+
const fieldContext = React.useContext(FormFieldContext)
+
const itemContext = React.useContext(FormItemContext)
+
const { getFieldState, formState } = useFormContext()
+
+
const fieldState = getFieldState(fieldContext.name, formState)
+
+
if (!fieldContext) {
+
throw new Error("useFormField should be used within <FormField>")
+
}
+
+
const { id } = itemContext
+
+
return {
+
id,
+
name: fieldContext.name,
+
formItemId: `${id}-form-item`,
+
formDescriptionId: `${id}-form-item-description`,
+
formMessageId: `${id}-form-item-message`,
+
...fieldState,
+
}
+
}
+
+
type FormItemContextValue = {
+
id: string
+
}
+
+
const FormItemContext = React.createContext<FormItemContextValue>(
+
{} as FormItemContextValue
+
)
+
+
const FormItem = React.forwardRef<
+
HTMLDivElement,
+
React.HTMLAttributes<HTMLDivElement>
+
>(({ className, ...props }, ref) => {
+
const id = React.useId()
+
+
return (
+
<FormItemContext.Provider value={{ id }}>
+
<div ref={ref} className={cn("space-y-2", className)} {...props} />
+
</FormItemContext.Provider>
+
)
+
})
+
FormItem.displayName = "FormItem"
+
+
const FormLabel = React.forwardRef<
+
React.ElementRef<typeof LabelPrimitive.Root>,
+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
+
>(({ className, ...props }, ref) => {
+
const { error, formItemId } = useFormField()
+
+
return (
+
<Label
+
ref={ref}
+
className={cn(error && "text-destructive", className)}
+
htmlFor={formItemId}
+
{...props}
+
/>
+
)
+
})
+
FormLabel.displayName = "FormLabel"
+
+
const FormControl = React.forwardRef<
+
React.ElementRef<typeof Slot>,
+
React.ComponentPropsWithoutRef<typeof Slot>
+
>(({ ...props }, ref) => {
+
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+
return (
+
<Slot
+
ref={ref}
+
id={formItemId}
+
aria-describedby={
+
!error
+
? `${formDescriptionId}`
+
: `${formDescriptionId} ${formMessageId}`
+
}
+
aria-invalid={!!error}
+
{...props}
+
/>
+
)
+
})
+
FormControl.displayName = "FormControl"
+
+
const FormDescription = React.forwardRef<
+
HTMLParagraphElement,
+
React.HTMLAttributes<HTMLParagraphElement>
+
>(({ className, ...props }, ref) => {
+
const { formDescriptionId } = useFormField()
+
+
return (
+
<p
+
ref={ref}
+
id={formDescriptionId}
+
className={cn("text-[0.8rem] text-muted-foreground", className)}
+
{...props}
+
/>
+
)
+
})
+
FormDescription.displayName = "FormDescription"
+
+
const FormMessage = React.forwardRef<
+
HTMLParagraphElement,
+
React.HTMLAttributes<HTMLParagraphElement>
+
>(({ className, children, ...props }, ref) => {
+
const { error, formMessageId } = useFormField()
+
const body = error ? String(error?.message) : children
+
+
if (!body) {
+
return null
+
}
+
+
return (
+
<p
+
ref={ref}
+
id={formMessageId}
+
className={cn("text-[0.8rem] font-medium text-destructive", className)}
+
{...props}
+
>
+
{body}
+
</p>
+
)
+
})
+
FormMessage.displayName = "FormMessage"
+
+
export {
+
useFormField,
+
Form,
+
FormItem,
+
FormLabel,
+
FormControl,
+
FormDescription,
+
FormMessage,
+
FormField,
+
}
+22
frontend/src/components/ui/input.tsx
···
+
import * as React from "react"
+
+
import { cn } from "@/lib/utils"
+
+
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
+
({ className, type, ...props }, ref) => {
+
return (
+
<input
+
type={type}
+
className={cn(
+
"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",
+
className
+
)}
+
ref={ref}
+
{...props}
+
/>
+
)
+
}
+
)
+
Input.displayName = "Input"
+
+
export { Input }
+24
frontend/src/components/ui/label.tsx
···
+
import * as React from "react"
+
import * as LabelPrimitive from "@radix-ui/react-label"
+
import { cva, type VariantProps } from "class-variance-authority"
+
+
import { cn } from "@/lib/utils"
+
+
const labelVariants = cva(
+
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+
)
+
+
const Label = React.forwardRef<
+
React.ElementRef<typeof LabelPrimitive.Root>,
+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
+
VariantProps<typeof labelVariants>
+
>(({ className, ...props }, ref) => (
+
<LabelPrimitive.Root
+
ref={ref}
+
className={cn(labelVariants(), className)}
+
{...props}
+
/>
+
))
+
Label.displayName = LabelPrimitive.Root.displayName
+
+
export { Label }
+120
frontend/src/components/ui/table.tsx
···
+
import * as React from "react"
+
+
import { cn } from "@/lib/utils"
+
+
const Table = React.forwardRef<
+
HTMLTableElement,
+
React.HTMLAttributes<HTMLTableElement>
+
>(({ className, ...props }, ref) => (
+
<div className="relative w-full overflow-auto">
+
<table
+
ref={ref}
+
className={cn("w-full caption-bottom text-sm", className)}
+
{...props}
+
/>
+
</div>
+
))
+
Table.displayName = "Table"
+
+
const TableHeader = React.forwardRef<
+
HTMLTableSectionElement,
+
React.HTMLAttributes<HTMLTableSectionElement>
+
>(({ className, ...props }, ref) => (
+
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
+
))
+
TableHeader.displayName = "TableHeader"
+
+
const TableBody = React.forwardRef<
+
HTMLTableSectionElement,
+
React.HTMLAttributes<HTMLTableSectionElement>
+
>(({ className, ...props }, ref) => (
+
<tbody
+
ref={ref}
+
className={cn("[&_tr:last-child]:border-0", className)}
+
{...props}
+
/>
+
))
+
TableBody.displayName = "TableBody"
+
+
const TableFooter = React.forwardRef<
+
HTMLTableSectionElement,
+
React.HTMLAttributes<HTMLTableSectionElement>
+
>(({ className, ...props }, ref) => (
+
<tfoot
+
ref={ref}
+
className={cn(
+
"border-t bg-muted/50 font-medium last:[&>tr]:border-b-0",
+
className
+
)}
+
{...props}
+
/>
+
))
+
TableFooter.displayName = "TableFooter"
+
+
const TableRow = React.forwardRef<
+
HTMLTableRowElement,
+
React.HTMLAttributes<HTMLTableRowElement>
+
>(({ className, ...props }, ref) => (
+
<tr
+
ref={ref}
+
className={cn(
+
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
+
className
+
)}
+
{...props}
+
/>
+
))
+
TableRow.displayName = "TableRow"
+
+
const TableHead = React.forwardRef<
+
HTMLTableCellElement,
+
React.ThHTMLAttributes<HTMLTableCellElement>
+
>(({ className, ...props }, ref) => (
+
<th
+
ref={ref}
+
className={cn(
+
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+
className
+
)}
+
{...props}
+
/>
+
))
+
TableHead.displayName = "TableHead"
+
+
const TableCell = React.forwardRef<
+
HTMLTableCellElement,
+
React.TdHTMLAttributes<HTMLTableCellElement>
+
>(({ className, ...props }, ref) => (
+
<td
+
ref={ref}
+
className={cn(
+
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+
className
+
)}
+
{...props}
+
/>
+
))
+
TableCell.displayName = "TableCell"
+
+
const TableCaption = React.forwardRef<
+
HTMLTableCaptionElement,
+
React.HTMLAttributes<HTMLTableCaptionElement>
+
>(({ className, ...props }, ref) => (
+
<caption
+
ref={ref}
+
className={cn("mt-4 text-sm text-muted-foreground", className)}
+
{...props}
+
/>
+
))
+
TableCaption.displayName = "TableCaption"
+
+
export {
+
Table,
+
TableHeader,
+
TableBody,
+
TableFooter,
+
TableHead,
+
TableRow,
+
TableCell,
+
TableCaption,
+
}
+53
frontend/src/components/ui/tabs.tsx
···
+
import * as React from "react"
+
import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+
import { cn } from "@/lib/utils"
+
+
const Tabs = TabsPrimitive.Root
+
+
const TabsList = React.forwardRef<
+
React.ElementRef<typeof TabsPrimitive.List>,
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
+
>(({ className, ...props }, ref) => (
+
<TabsPrimitive.List
+
ref={ref}
+
className={cn(
+
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
+
className
+
)}
+
{...props}
+
/>
+
))
+
TabsList.displayName = TabsPrimitive.List.displayName
+
+
const TabsTrigger = React.forwardRef<
+
React.ElementRef<typeof TabsPrimitive.Trigger>,
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
+
>(({ className, ...props }, ref) => (
+
<TabsPrimitive.Trigger
+
ref={ref}
+
className={cn(
+
"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",
+
className
+
)}
+
{...props}
+
/>
+
))
+
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+
const TabsContent = React.forwardRef<
+
React.ElementRef<typeof TabsPrimitive.Content>,
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
+
>(({ className, ...props }, ref) => (
+
<TabsPrimitive.Content
+
ref={ref}
+
className={cn(
+
"mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
+
className
+
)}
+
{...props}
+
/>
+
))
+
TabsContent.displayName = TabsPrimitive.Content.displayName
+
+
export { Tabs, TabsList, TabsTrigger, TabsContent }
+129
frontend/src/components/ui/toast.tsx
···
+
"use client"
+
+
import * as React from "react"
+
import * as ToastPrimitives from "@radix-ui/react-toast"
+
import { cva, type VariantProps } from "class-variance-authority"
+
import { X } from "lucide-react"
+
+
import { cn } from "@/lib/utils"
+
+
const ToastProvider = ToastPrimitives.Provider
+
+
const ToastViewport = React.forwardRef<
+
React.ElementRef<typeof ToastPrimitives.Viewport>,
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
+
>(({ className, ...props }, ref) => (
+
<ToastPrimitives.Viewport
+
ref={ref}
+
className={cn(
+
"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]",
+
className
+
)}
+
{...props}
+
/>
+
))
+
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
+
+
const toastVariants = cva(
+
"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",
+
{
+
variants: {
+
variant: {
+
default: "border bg-background text-foreground",
+
destructive:
+
"destructive group border-destructive bg-destructive text-destructive-foreground",
+
},
+
},
+
defaultVariants: {
+
variant: "default",
+
},
+
}
+
)
+
+
const Toast = React.forwardRef<
+
React.ElementRef<typeof ToastPrimitives.Root>,
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
+
VariantProps<typeof toastVariants>
+
>(({ className, variant, ...props }, ref) => {
+
return (
+
<ToastPrimitives.Root
+
ref={ref}
+
className={cn(toastVariants({ variant }), className)}
+
{...props}
+
/>
+
)
+
})
+
Toast.displayName = ToastPrimitives.Root.displayName
+
+
const ToastAction = React.forwardRef<
+
React.ElementRef<typeof ToastPrimitives.Action>,
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
+
>(({ className, ...props }, ref) => (
+
<ToastPrimitives.Action
+
ref={ref}
+
className={cn(
+
"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",
+
className
+
)}
+
{...props}
+
/>
+
))
+
ToastAction.displayName = ToastPrimitives.Action.displayName
+
+
const ToastClose = React.forwardRef<
+
React.ElementRef<typeof ToastPrimitives.Close>,
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
+
>(({ className, ...props }, ref) => (
+
<ToastPrimitives.Close
+
ref={ref}
+
className={cn(
+
"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",
+
className
+
)}
+
toast-close=""
+
{...props}
+
>
+
<X className="h-4 w-4" />
+
</ToastPrimitives.Close>
+
))
+
ToastClose.displayName = ToastPrimitives.Close.displayName
+
+
const ToastTitle = React.forwardRef<
+
React.ElementRef<typeof ToastPrimitives.Title>,
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
+
>(({ className, ...props }, ref) => (
+
<ToastPrimitives.Title
+
ref={ref}
+
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
+
{...props}
+
/>
+
))
+
ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+
const ToastDescription = React.forwardRef<
+
React.ElementRef<typeof ToastPrimitives.Description>,
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
+
>(({ className, ...props }, ref) => (
+
<ToastPrimitives.Description
+
ref={ref}
+
className={cn("text-sm opacity-90", className)}
+
{...props}
+
/>
+
))
+
ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
+
+
type ToastActionElement = React.ReactElement<typeof ToastAction>
+
+
export {
+
type ToastProps,
+
type ToastActionElement,
+
ToastProvider,
+
ToastViewport,
+
Toast,
+
ToastTitle,
+
ToastDescription,
+
ToastClose,
+
ToastAction,
+
}
+35
frontend/src/components/ui/toaster.tsx
···
+
"use client"
+
+
import { useToast } from "@/hooks/use-toast"
+
import {
+
Toast,
+
ToastClose,
+
ToastDescription,
+
ToastProvider,
+
ToastTitle,
+
ToastViewport,
+
} from "@/components/ui/toast"
+
+
export function Toaster() {
+
const { toasts } = useToast()
+
+
return (
+
<ToastProvider>
+
{toasts.map(function ({ id, title, description, action, ...props }) {
+
return (
+
<Toast key={id} {...props}>
+
<div className="grid gap-1">
+
{title && <ToastTitle>{title}</ToastTitle>}
+
{description && (
+
<ToastDescription>{description}</ToastDescription>
+
)}
+
</div>
+
{action}
+
<ToastClose />
+
</Toast>
+
)
+
})}
+
<ToastViewport />
+
</ToastProvider>
+
)
+
}
+73
frontend/src/context/AuthContext.tsx
···
+
import { createContext, useContext, useEffect, useState } from 'react';
+
import { User } from '../types/api';
+
import * as api from '../api/client';
+
+
interface AuthContextType {
+
user: User | null;
+
login: (email: string, password: string) => Promise<void>;
+
register: (email: string, password: string, adminToken: string) => Promise<void>;
+
logout: () => void;
+
isLoading: boolean;
+
}
+
+
const AuthContext = createContext<AuthContextType | undefined>(undefined);
+
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
+
const [user, setUser] = useState<User | null>(null);
+
const [isLoading, setIsLoading] = useState(true);
+
+
useEffect(() => {
+
const token = localStorage.getItem('token');
+
if (token) {
+
const userData = JSON.parse(localStorage.getItem('user') || 'null');
+
setUser(userData);
+
}
+
setIsLoading(false);
+
+
const handleUnauthorized = () => {
+
setUser(null);
+
};
+
+
window.addEventListener('unauthorized', handleUnauthorized);
+
+
return () => {
+
window.removeEventListener('unauthorized', handleUnauthorized);
+
};
+
}, []);
+
+
const login = async (email: string, password: string) => {
+
const response = await api.login(email, password);
+
const { token, user } = response;
+
localStorage.setItem('token', token);
+
localStorage.setItem('user', JSON.stringify(user));
+
setUser(user);
+
};
+
+
const register = async (email: string, password: string, adminToken: string) => {
+
const response = await api.register(email, password, adminToken);
+
const { token, user } = response;
+
localStorage.setItem('token', token);
+
localStorage.setItem('user', JSON.stringify(user));
+
setUser(user);
+
};
+
+
const logout = () => {
+
localStorage.removeItem('token');
+
localStorage.removeItem('user');
+
setUser(null);
+
};
+
+
return (
+
<AuthContext.Provider value={{ user, login, register, logout, isLoading }}>
+
{children}
+
</AuthContext.Provider>
+
);
+
}
+
+
export function useAuth() {
+
const context = useContext(AuthContext);
+
if (context === undefined) {
+
throw new Error('useAuth must be used within an AuthProvider');
+
}
+
return context;
+
}
+191
frontend/src/hooks/use-toast.ts
···
+
import * as React from "react"
+
+
import type {
+
ToastActionElement,
+
ToastProps,
+
} from "@/components/ui/toast"
+
+
const TOAST_LIMIT = 1
+
const TOAST_REMOVE_DELAY = 1000000
+
+
type ToasterToast = ToastProps & {
+
id: string
+
title?: React.ReactNode
+
description?: React.ReactNode
+
action?: ToastActionElement
+
}
+
+
const actionTypes = {
+
ADD_TOAST: "ADD_TOAST",
+
UPDATE_TOAST: "UPDATE_TOAST",
+
DISMISS_TOAST: "DISMISS_TOAST",
+
REMOVE_TOAST: "REMOVE_TOAST",
+
} as const
+
+
let count = 0
+
+
function genId() {
+
count = (count + 1) % Number.MAX_SAFE_INTEGER
+
return count.toString()
+
}
+
+
type ActionType = typeof actionTypes
+
+
type Action =
+
| {
+
type: ActionType["ADD_TOAST"]
+
toast: ToasterToast
+
}
+
| {
+
type: ActionType["UPDATE_TOAST"]
+
toast: Partial<ToasterToast>
+
}
+
| {
+
type: ActionType["DISMISS_TOAST"]
+
toastId?: ToasterToast["id"]
+
}
+
| {
+
type: ActionType["REMOVE_TOAST"]
+
toastId?: ToasterToast["id"]
+
}
+
+
interface State {
+
toasts: ToasterToast[]
+
}
+
+
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
+
+
const addToRemoveQueue = (toastId: string) => {
+
if (toastTimeouts.has(toastId)) {
+
return
+
}
+
+
const timeout = setTimeout(() => {
+
toastTimeouts.delete(toastId)
+
dispatch({
+
type: "REMOVE_TOAST",
+
toastId: toastId,
+
})
+
}, TOAST_REMOVE_DELAY)
+
+
toastTimeouts.set(toastId, timeout)
+
}
+
+
export const reducer = (state: State, action: Action): State => {
+
switch (action.type) {
+
case "ADD_TOAST":
+
return {
+
...state,
+
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+
}
+
+
case "UPDATE_TOAST":
+
return {
+
...state,
+
toasts: state.toasts.map((t) =>
+
t.id === action.toast.id ? { ...t, ...action.toast } : t
+
),
+
}
+
+
case "DISMISS_TOAST": {
+
const { toastId } = action
+
+
// ! Side effects ! - This could be extracted into a dismissToast() action,
+
// but I'll keep it here for simplicity
+
if (toastId) {
+
addToRemoveQueue(toastId)
+
} else {
+
state.toasts.forEach((toast) => {
+
addToRemoveQueue(toast.id)
+
})
+
}
+
+
return {
+
...state,
+
toasts: state.toasts.map((t) =>
+
t.id === toastId || toastId === undefined
+
? {
+
...t,
+
open: false,
+
}
+
: t
+
),
+
}
+
}
+
case "REMOVE_TOAST":
+
if (action.toastId === undefined) {
+
return {
+
...state,
+
toasts: [],
+
}
+
}
+
return {
+
...state,
+
toasts: state.toasts.filter((t) => t.id !== action.toastId),
+
}
+
}
+
}
+
+
const listeners: Array<(state: State) => void> = []
+
+
let memoryState: State = { toasts: [] }
+
+
function dispatch(action: Action) {
+
memoryState = reducer(memoryState, action)
+
listeners.forEach((listener) => {
+
listener(memoryState)
+
})
+
}
+
+
type Toast = Omit<ToasterToast, "id">
+
+
function toast({ ...props }: Toast) {
+
const id = genId()
+
+
const update = (props: ToasterToast) =>
+
dispatch({
+
type: "UPDATE_TOAST",
+
toast: { ...props, id },
+
})
+
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
+
+
dispatch({
+
type: "ADD_TOAST",
+
toast: {
+
...props,
+
id,
+
open: true,
+
onOpenChange: (open) => {
+
if (!open) dismiss()
+
},
+
},
+
})
+
+
return {
+
id: id,
+
dismiss,
+
update,
+
}
+
}
+
+
function useToast() {
+
const [state, setState] = React.useState<State>(memoryState)
+
+
React.useEffect(() => {
+
listeners.push(setState)
+
return () => {
+
const index = listeners.indexOf(setState)
+
if (index > -1) {
+
listeners.splice(index, 1)
+
}
+
}
+
}, [state])
+
+
return {
+
...state,
+
toast,
+
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
+
}
+
}
+
+
export { useToast, toast }
+147 -52
frontend/src/index.css
···
-
:root {
-
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
-
line-height: 1.5;
-
font-weight: 400;
+
@import "tailwindcss";
+
+
@variant dark (&:where(.dark, .dark *));
-
color-scheme: light dark;
-
color: rgba(255, 255, 255, 0.87);
-
background-color: #242424;
+
@plugin "tailwindcss-animate";
-
font-synthesis: none;
-
text-rendering: optimizeLegibility;
-
-webkit-font-smoothing: antialiased;
-
-moz-osx-font-smoothing: grayscale;
+
@theme inline {
+
--color-background: var(--background);
+
--color-foreground: var(--foreground);
+
--color-card: var(--card);
+
--color-card-foreground: var(--card-foreground);
+
--color-popover: var(--popover);
+
--color-popover-foreground: var(--popover-foreground);
+
--color-primary: var(--primary);
+
--color-primary-foreground: var(--primary-foreground);
+
--color-secondary: var(--secondary);
+
--color-secondary-foreground: var(--secondary-foreground);
+
--color-muted: var(--muted);
+
--color-muted-foreground: var(--muted-foreground);
+
--color-accent: var(--accent);
+
--color-accent-foreground: var(--accent-foreground);
+
--color-destructive: var(--destructive);
+
--color-destructive-foreground: var(--destructive-foreground);
+
--color-border: var(--border);
+
--color-input: var(--input);
+
--color-ring: var(--ring);
+
--color-chart-1: var(--chart-1);
+
--color-chart-2: var(--chart-2);
+
--color-chart-3: var(--chart-3);
+
--color-chart-4: var(--chart-4);
+
--color-chart-5: var(--chart-5);
+
--radius-lg: var(--radius);
+
--radius-md: calc(var(--radius) - 2px);
+
--radius-sm: calc(var(--radius) - 4px);
}
-
a {
-
font-weight: 500;
-
color: #646cff;
-
text-decoration: inherit;
+
/* Default theme */
+
:root {
+
--background: var(--color-white);
+
--foreground: var(--color-slate-900);
+
--card: var(--color-white);
+
--card-foreground: var(--color-slate-900);
+
--popover: var(--color-white);
+
--popover-foreground: var(--color-slate-900);
+
--primary: var(--color-slate-900);
+
--primary-foreground: var(--color-slate-50);
+
--secondary: var(--color-slate-100);
+
--secondary-foreground: var(--color-slate-900);
+
--muted: var(--color-slate-100);
+
--muted-foreground: var(--color-slate-500);
+
--accent: var(--color-slate-100);
+
--accent-foreground: var(--color-slate-900);
+
--destructive: var(--color-rose-500);
+
--destructive-foreground: var(--color-slate-50);
+
--border: var(--color-slate-200);
+
--input: var(--color-slate-200);
+
--ring: var(--color-slate-400);
+
--chart-1: hsl(12 76% 61%);
+
--chart-2: hsl(173 58% 39%);
+
--chart-3: hsl(197 37% 24%);
+
--chart-4: hsl(43 74% 66%);
+
--chart-5: hsl(27 87% 67%);
+
--radius: 0.5rem;
}
-
a:hover {
-
color: #535bf2;
+
+
.dark {
+
--background: var(--color-zinc-950);
+
--foreground: var(--color-zinc-50);
+
--card: var(--color-zinc-950);
+
--card-foreground: var(--color-zinc-50);
+
--popover: var(--color-zinc-950);
+
--popover-foreground: var(--color-zinc-50);
+
--primary: var(--color-zinc-50);
+
--primary-foreground: var(--color-zinc-900);
+
--secondary: var(--color-zinc-800);
+
--secondary-foreground: var(--color-zinc-50);
+
--muted: var(--color-zinc-800);
+
--muted-foreground: var(--color-zinc-400);
+
--accent: var(--color-zinc-800);
+
--accent-foreground: var(--color-zinc-50);
+
--destructive: var(--color-rose-700);
+
--destructive-foreground: var(--color-zinc-50);
+
--border: var(--color-zinc-800);
+
--input: var(--color-zinc-800);
+
--ring: var(--color-zinc-300);
+
--chart-1: hsl(220 70% 50%);
+
--chart-2: hsl(160 60% 45%);
+
--chart-3: hsl(30 80% 55%);
+
--chart-4: hsl(280 65% 60%);
+
--chart-5: hsl(340 75% 55%);
}
-
body {
-
margin: 0;
-
display: flex;
-
place-items: center;
-
min-width: 320px;
-
min-height: 100vh;
+
[data-theme="example"] {
+
--background: var(--color-blue-50);
+
--foreground: var(--color-gray-900);
+
--card: var(--color-blue-100);
+
--card-foreground: var(--color-gray-800);
+
--popover: var(--color-blue-50);
+
--popover-foreground: var(--color-gray-800);
+
--primary: var(--color-blue-100);
+
--primary-foreground: var(--color-gray-900);
+
--secondary: var(--color-blue-400);
+
--secondary-foreground: var(--color-gray-800);
+
--muted: var(--color-emerald-100);
+
--muted-foreground: var(--color-gray-600);
+
--accent: var(--color-emerald-200);
+
--accent-foreground: var(--color-gray-800);
+
--destructive: var(--color-red-700);
+
--destructive-foreground: var(--color-gray-200);
+
--border: var(--color-blue-600);
+
--input: var(--color-blue-800);
+
--ring: var(--color-blue-100);
+
--radius: 0.3rem;
}
-
h1 {
-
font-size: 3.2em;
-
line-height: 1.1;
+
.dark [data-theme="example"],
+
.dark[data-theme="example"] {
+
--background: var(--color-gray-900);
+
--foreground: var(--color-gray-200);
+
--card: var(--color-gray-900);
+
--card-foreground: var(--color-gray-200);
+
--popover: var(--color-gray-950);
+
--popover-foreground: var(--color-gray-200);
+
--primary: var(--color-blue-500);
+
--primary-foreground: var(--color-blue-50);
+
--secondary: var(--color-blue-800);
+
--secondary-foreground: var(--color-blue-50);
+
--muted: var(--color-emerald-900);
+
--muted-foreground: var(--color-gray-500);
+
--accent: var(--color-emerald-900);
+
--accent-foreground: var(--color-gray-200);
+
--destructive: var(--color-red-700);
+
--destructive-foreground: var(--color-gray-200);
+
--border: var(--color-blue-800);
+
--input: var(--color-blue-800);
+
--ring: var(--color-blue-100);
+
--radius: 0.3rem;
}
-
button {
-
border-radius: 8px;
-
border: 1px solid transparent;
-
padding: 0.6em 1.2em;
-
font-size: 1em;
-
font-weight: 500;
-
font-family: inherit;
-
background-color: #1a1a1a;
-
cursor: pointer;
-
transition: border-color 0.25s;
+
html,
+
body {
+
background-color: var(--background);
+
color: var(--foreground);
}
-
button:hover {
-
border-color: #646cff;
+
+
body * {
+
border-color: var(--border);
}
-
button:focus,
-
button:focus-visible {
-
outline: 4px auto -webkit-focus-ring-color;
+
+
/* Animation utilities */
+
@keyframes enter {
+
from {
+
opacity: var(--tw-enter-opacity, 1);
+
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));
+
}
}
-
@media (prefers-color-scheme: light) {
-
:root {
-
color: #213547;
-
background-color: #ffffff;
-
}
-
a:hover {
-
color: #747bff;
-
}
-
button {
-
background-color: #f9f9f9;
+
@keyframes exit {
+
to {
+
opacity: var(--tw-exit-opacity, 1);
+
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));
}
-
}
+
}
+6
frontend/src/lib/utils.ts
···
+
import { clsx, type ClassValue } from "clsx"
+
import { twMerge } from "tailwind-merge"
+
+
export function cn(...inputs: ClassValue[]) {
+
return twMerge(clsx(inputs))
+
}
+26
frontend/src/types/api.ts
···
clicks: number;
}
+
export interface User {
+
id: number;
+
email: string;
+
}
+
+
export interface AuthResponse {
+
token: string;
+
user: User;
+
}
+
export interface ApiError {
error: string;
}
+
export interface ClickStats {
+
date: string;
+
clicks: number;
+
}
+
+
export interface SourceStats {
+
date: string;
+
source: string;
+
count: number;
+
}
+
+
export interface RegisterRequest {
+
email: string;
+
password: string;
+
admin_token: string;
+
}
+16 -6
frontend/tsconfig.app.json
···
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
-
"lib": ["ES2020", "DOM", "DOM.Iterable"],
+
"lib": [
+
"ES2020",
+
"DOM",
+
"DOM.Iterable"
+
],
"module": "ESNext",
"skipLibCheck": true,
-
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
···
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
-
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
-
"noUncheckedSideEffectImports": true
+
"noUncheckedSideEffectImports": true,
+
"baseUrl": ".",
+
"paths": {
+
"@/*": [
+
"./src/*"
+
]
+
}
},
-
"include": ["src"]
-
}
+
"include": [
+
"src"
+
]
+
}
+16 -4
frontend/tsconfig.json
···
{
"files": [],
"references": [
-
{ "path": "./tsconfig.app.json" },
-
{ "path": "./tsconfig.node.json" }
-
]
-
}
+
{
+
"path": "./tsconfig.app.json"
+
},
+
{
+
"path": "./tsconfig.node.json"
+
}
+
],
+
"compilerOptions": {
+
"baseUrl": ".",
+
"paths": {
+
"@/*": [
+
"./src/*"
+
]
+
}
+
}
+
}
+30 -11
frontend/vite.config.ts
···
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+
import tailwindcss from '@tailwindcss/vite'
+
import path from "path"
-
export default defineConfig({
-
plugins: [react()],
-
server: {
-
proxy: {
-
'/api': {
-
target: 'http://localhost:8080',
-
changeOrigin: true,
+
export default defineConfig(({ command }) => {
+
if (command === 'serve') { //command == 'dev'
+
return {
+
server: {
+
proxy: {
+
'/api': {
+
target: process.env.VITE_API_URL || 'http://localhost:8080',
+
changeOrigin: true,
+
},
+
},
+
},
+
plugins: [react(), tailwindcss()],
+
resolve: {
+
alias: {
+
"@": path.resolve(__dirname, "./src"),
+
},
+
},
+
}
+
} else { //command === 'build'
+
return {
+
plugins: [react(), tailwindcss()],
+
resolve: {
+
alias: {
+
"@": path.resolve(__dirname, "./src"),
+
},
},
-
},
-
},
-
})
-
+
}
+
}
+
})
+3
migrations/20250219000000_extend_short_code.sql
···
+
-- PostgreSQL migration
+
ALTER TABLE links ALTER COLUMN short_code TYPE VARCHAR(32);
+
+42
migrations/sqlite/20250125000000_init.sql
···
+
-- Enable foreign key support
+
PRAGMA foreign_keys = ON;
+
+
-- Add Migration Version
+
CREATE TABLE IF NOT EXISTS _sqlx_migrations (
+
version INTEGER PRIMARY KEY,
+
description TEXT NOT NULL,
+
installed_on TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+
);
+
+
-- Create users table
+
CREATE TABLE users (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
email VARCHAR(255) NOT NULL UNIQUE,
+
password_hash TEXT NOT NULL
+
);
+
+
-- Create links table
+
CREATE TABLE links (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
original_url TEXT NOT NULL,
+
short_code VARCHAR(8) NOT NULL UNIQUE,
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
clicks INTEGER NOT NULL DEFAULT 0,
+
user_id INTEGER,
+
FOREIGN KEY (user_id) REFERENCES users(id)
+
);
+
+
-- Create clicks table
+
CREATE TABLE clicks (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
link_id INTEGER,
+
source TEXT,
+
query_source TEXT,
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
FOREIGN KEY (link_id) REFERENCES links(id)
+
);
+
+
-- Create indexes
+
CREATE INDEX idx_short_code ON links(short_code);
+
CREATE INDEX idx_user_id ON links(user_id);
+
CREATE INDEX idx_link_id ON clicks(link_id);
readme_img/mainview.jpg

This is a binary file and will not be displayed.

readme_img/statview.jpg

This is a binary file and will not be displayed.

+8 -7
src/auth.rs
···
+
use crate::{error::AppError, models::Claims};
use actix_web::{dev::Payload, FromRequest, HttpRequest};
use jsonwebtoken::{decode, DecodingKey, Validation};
use std::future::{ready, Ready};
-
use crate::{error::AppError, models::Claims};
pub struct AuthenticatedUser {
pub user_id: i32,
···
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
-
let auth_header = req.headers()
+
let auth_header = req
+
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok());
if let Some(auth_header) = auth_header {
if auth_header.starts_with("Bearer ") {
let token = &auth_header[7..];
-
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
-
+
let secret =
+
std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
match decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
-
&Validation::default()
+
&Validation::default(),
) {
Ok(token_data) => {
return ready(Ok(AuthenticatedUser {
···
}
}
}
-
ready(Err(AppError::Unauthorized))
}
-
}
+
}
+
+697 -121
src/handlers.rs
···
-
use actix_web::{web, HttpResponse, Responder, HttpRequest};
-
use jsonwebtoken::{encode, Header, EncodingKey};use crate::{error::AppError, models::{AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse}, AppState};
-
use regex::Regex;
-
use argon2::{password_hash::{rand_core::OsRng, SaltString}, PasswordVerifier};
-
use lazy_static::lazy_static;
-
use argon2::{Argon2, PasswordHash, PasswordHasher};
use crate::auth::AuthenticatedUser;
+
use crate::{
+
error::AppError,
+
models::{
+
AuthResponse, Claims, ClickStats, CreateLink, DatabasePool, Link, LoginRequest,
+
RegisterRequest, SourceStats, User, UserResponse,
+
},
+
AppState,
+
};
+
use actix_web::{web, HttpRequest, HttpResponse, Responder};
+
use argon2::{
+
password_hash::{rand_core::OsRng, SaltString},
+
PasswordVerifier,
+
};
+
use argon2::{Argon2, PasswordHash, PasswordHasher};
+
use jsonwebtoken::{encode, EncodingKey, Header};
+
use lazy_static::lazy_static;
+
use regex::Regex;
+
use serde_json::json;
+
use sqlx::{Postgres, Sqlite};
lazy_static! {
static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap();
···
payload: web::Json<CreateLink>,
) -> Result<impl Responder, AppError> {
tracing::debug!("Creating short URL with user_id: {}", user.user_id);
-
validate_url(&payload.url)?;
-
+
let short_code = if let Some(ref custom_code) = payload.custom_code {
validate_custom_code(custom_code)?;
-
-
tracing::debug!("Checking if custom code {} exists", custom_code);
-
// Check if code is already taken
-
if let Some(_) = sqlx::query_as::<_, Link>(
-
"SELECT * FROM links WHERE short_code = $1"
-
)
-
.bind(custom_code)
-
.fetch_optional(&state.db)
-
.await? {
+
+
// Check if code exists using match on pool type
+
let exists = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1")
+
.bind(custom_code)
+
.fetch_optional(pool)
+
.await?
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = ?1")
+
.bind(custom_code)
+
.fetch_optional(pool)
+
.await?
+
}
+
};
+
+
if exists.is_some() {
return Err(AppError::InvalidInput(
-
"Custom code already taken".to_string()
+
"Custom code already taken".to_string(),
));
}
-
custom_code.clone()
} else {
generate_short_code()
};
-
-
// Start transaction
-
let mut tx = state.db.begin().await?;
-
-
tracing::debug!("Inserting new link with short_code: {}", short_code);
-
let link = sqlx::query_as::<_, Link>(
-
"INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *"
-
)
-
.bind(&payload.url)
-
.bind(&short_code)
-
.bind(user.user_id)
-
.fetch_one(&mut *tx)
-
.await?;
-
-
if let Some(ref source) = payload.source {
-
tracing::debug!("Adding click source: {}", source);
-
sqlx::query(
-
"INSERT INTO clicks (link_id, source) VALUES ($1, $2)"
-
)
-
.bind(link.id)
-
.bind(source)
-
.execute(&mut *tx)
-
.await?;
-
}
-
-
tx.commit().await?;
-
Ok(HttpResponse::Created().json(link))
+
+
// Start transaction based on pool type
+
let result = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
+
let link = sqlx::query_as::<_, Link>(
+
"INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *"
+
)
+
.bind(&payload.url)
+
.bind(&short_code)
+
.bind(user.user_id)
+
.fetch_one(&mut *tx)
+
.await?;
+
+
if let Some(ref source) = payload.source {
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)")
+
.bind(link.id)
+
.bind(source)
+
.execute(&mut *tx)
+
.await?;
+
}
+
+
tx.commit().await?;
+
link
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
+
let link = sqlx::query_as::<_, Link>(
+
"INSERT INTO links (original_url, short_code, user_id) VALUES (?1, ?2, ?3) RETURNING *"
+
)
+
.bind(&payload.url)
+
.bind(&short_code)
+
.bind(user.user_id)
+
.fetch_one(&mut *tx)
+
.await?;
+
+
if let Some(ref source) = payload.source {
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES (?1, ?2)")
+
.bind(link.id)
+
.bind(source)
+
.execute(&mut *tx)
+
.await?;
+
}
+
+
tx.commit().await?;
+
link
+
}
+
};
+
+
Ok(HttpResponse::Created().json(result))
}
fn validate_custom_code(code: &str) -> Result<(), AppError> {
···
"Custom code must be 1-32 characters long and contain only letters, numbers, underscores, and hyphens".to_string()
));
}
-
+
// Add reserved words check
let reserved_words = ["api", "health", "admin", "static", "assets"];
if reserved_words.contains(&code.to_lowercase().as_str()) {
return Err(AppError::InvalidInput(
-
"This code is reserved and cannot be used".to_string()
+
"This code is reserved and cannot be used".to_string(),
));
}
-
+
Ok(())
}
-
fn validate_url(url: &String) -> Result<(), AppError> {
+
fn validate_url(url: &str) -> Result<(), AppError> {
if url.is_empty() {
return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
}
if !url.starts_with("http://") && !url.starts_with("https://") {
-
return Err(AppError::InvalidInput("URL must start with http:// or https://".to_string()));
+
return Err(AppError::InvalidInput(
+
"URL must start with http:// or https://".to_string(),
+
));
}
Ok(())
}
···
req: HttpRequest,
) -> Result<impl Responder, AppError> {
let short_code = path.into_inner();
-
+
// Extract query source if present
-
let query_source = req.uri()
+
let query_source = req
+
.uri()
.query()
.and_then(|q| web::Query::<std::collections::HashMap<String, String>>::from_query(q).ok())
.and_then(|params| params.get("source").cloned());
-
let mut tx = state.db.begin().await?;
-
-
let link = sqlx::query_as::<_, Link>(
-
"UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *"
-
)
-
.bind(&short_code)
-
.fetch_optional(&mut *tx)
-
.await?;
+
let link = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let link = sqlx::query_as::<_, Link>(
+
"UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *",
+
)
+
.bind(&short_code)
+
.fetch_optional(&mut *tx)
+
.await?;
+
tx.commit().await?;
+
link
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let link = sqlx::query_as::<_, Link>(
+
"UPDATE links SET clicks = clicks + 1 WHERE short_code = ?1 RETURNING *",
+
)
+
.bind(&short_code)
+
.fetch_optional(&mut *tx)
+
.await?;
+
tx.commit().await?;
+
link
+
}
+
};
match link {
Some(link) => {
-
// Record click with both user agent and query source
-
let user_agent = req.headers()
-
.get("user-agent")
-
.and_then(|h| h.to_str().ok())
-
.unwrap_or("unknown")
-
.to_string();
+
// Handle click recording based on database type
+
match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let user_agent = req
+
.headers()
+
.get("user-agent")
+
.and_then(|h| h.to_str().ok())
+
.unwrap_or("unknown")
+
.to_string();
+
+
sqlx::query(
+
"INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)",
+
)
+
.bind(link.id)
+
.bind(user_agent)
+
.bind(query_source)
+
.execute(&mut *tx)
+
.await?;
-
sqlx::query(
-
"INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)"
-
)
-
.bind(link.id)
-
.bind(user_agent)
-
.bind(query_source)
-
.execute(&mut *tx)
-
.await?;
+
tx.commit().await?;
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let user_agent = req
+
.headers()
+
.get("user-agent")
+
.and_then(|h| h.to_str().ok())
+
.unwrap_or("unknown")
+
.to_string();
+
+
sqlx::query(
+
"INSERT INTO clicks (link_id, source, query_source) VALUES (?1, ?2, ?3)",
+
)
+
.bind(link.id)
+
.bind(user_agent)
+
.bind(query_source)
+
.execute(&mut *tx)
+
.await?;
-
tx.commit().await?;
+
tx.commit().await?;
+
}
+
};
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", link.original_url))
.finish())
-
},
+
}
None => Err(AppError::NotFound),
}
}
···
state: web::Data<AppState>,
user: AuthenticatedUser,
) -> Result<impl Responder, AppError> {
-
let links = sqlx::query_as::<_, Link>(
-
"SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC"
-
)
-
.bind(user.user_id)
-
.fetch_all(&state.db)
-
.await?;
+
let links = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query_as::<_, Link>(
+
"SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC",
+
)
+
.bind(user.user_id)
+
.fetch_all(pool)
+
.await?
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query_as::<_, Link>(
+
"SELECT * FROM links WHERE user_id = ?1 ORDER BY created_at DESC",
+
)
+
.bind(user.user_id)
+
.fetch_all(pool)
+
.await?
+
}
+
};
Ok(HttpResponse::Ok().json(links))
}
-
pub async fn health_check(
-
state: web::Data<AppState>,
-
) -> impl Responder {
-
match sqlx::query("SELECT 1").execute(&state.db).await {
-
Ok(_) => HttpResponse::Ok().json("Healthy"),
-
Err(_) => HttpResponse::ServiceUnavailable().json("Database unavailable"),
+
pub async fn health_check(state: web::Data<AppState>) -> impl Responder {
+
let is_healthy = match &state.db {
+
DatabasePool::Postgres(pool) => sqlx::query("SELECT 1").execute(pool).await.is_ok(),
+
DatabasePool::Sqlite(pool) => sqlx::query("SELECT 1").execute(pool).await.is_ok(),
+
};
+
+
if is_healthy {
+
HttpResponse::Ok().json("Healthy")
+
} else {
+
HttpResponse::ServiceUnavailable().json("Database unavailable")
}
}
fn generate_short_code() -> String {
use base62::encode;
use uuid::Uuid;
-
+
let uuid = Uuid::new_v4();
-
encode(uuid.as_u128() as u64).chars().take(8).collect()
+
encode(uuid.as_u128() as u64).chars().take(32).collect()
}
pub async fn register(
state: web::Data<AppState>,
payload: web::Json<RegisterRequest>,
) -> Result<impl Responder, AppError> {
-
let exists = sqlx::query!(
-
"SELECT id FROM users WHERE email = $1",
-
payload.email
-
)
-
.fetch_optional(&state.db)
-
.await?;
+
// Check if any users exist
+
let user_count = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let count = sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users")
+
.fetch_one(&mut *tx)
+
.await?
+
.0;
+
tx.commit().await?;
+
count
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let count = sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users")
+
.fetch_one(&mut *tx)
+
.await?
+
.0;
+
tx.commit().await?;
+
count
+
}
+
};
+
+
// If users exist, registration is closed - no exceptions
+
if user_count > 0 {
+
return Err(AppError::Auth("Registration is closed".to_string()));
+
}
+
+
// Verify admin token for first user
+
match (&state.admin_token, &payload.admin_token) {
+
(Some(stored_token), Some(provided_token)) if stored_token == provided_token => {
+
// Token matches, proceed with registration
+
}
+
_ => return Err(AppError::Auth("Invalid admin setup token".to_string())),
+
}
+
+
// Check if email already exists
+
let exists = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let exists =
+
sqlx::query_as::<Postgres, (i32,)>("SELECT id FROM users WHERE email = $1")
+
.bind(&payload.email)
+
.fetch_optional(&mut *tx)
+
.await?;
+
tx.commit().await?;
+
exists
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let exists = sqlx::query_as::<Sqlite, (i32,)>("SELECT id FROM users WHERE email = ?")
+
.bind(&payload.email)
+
.fetch_optional(&mut *tx)
+
.await?;
+
tx.commit().await?;
+
exists
+
}
+
};
if exists.is_some() {
return Err(AppError::Auth("Email already registered".to_string()));
···
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
-
let password_hash = argon2.hash_password(payload.password.as_bytes(), &salt)
+
let password_hash = argon2
+
.hash_password(payload.password.as_bytes(), &salt)
.map_err(|e| AppError::Auth(e.to_string()))?
.to_string();
-
let user = sqlx::query_as!(
-
User,
-
"INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *",
-
payload.email,
-
password_hash
-
)
-
.fetch_one(&state.db)
-
.await?;
+
// Insert new user
+
let user = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let user = sqlx::query_as::<Postgres, User>(
+
"INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *",
+
)
+
.bind(&payload.email)
+
.bind(&password_hash)
+
.fetch_one(&mut *tx)
+
.await?;
+
tx.commit().await?;
+
user
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let user = sqlx::query_as::<Sqlite, User>(
+
"INSERT INTO users (email, password_hash) VALUES (?, ?) RETURNING *",
+
)
+
.bind(&payload.email)
+
.bind(&password_hash)
+
.fetch_one(&mut *tx)
+
.await?;
+
tx.commit().await?;
+
user
+
}
+
};
let claims = Claims::new(user.id);
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
let token = encode(
&Header::default(),
&claims,
-
&EncodingKey::from_secret(secret.as_bytes())
-
).map_err(|e| AppError::Auth(e.to_string()))?;
+
&EncodingKey::from_secret(secret.as_bytes()),
+
)
+
.map_err(|e| AppError::Auth(e.to_string()))?;
Ok(HttpResponse::Ok().json(AuthResponse {
token,
···
state: web::Data<AppState>,
payload: web::Json<LoginRequest>,
) -> Result<impl Responder, AppError> {
-
let user = sqlx::query_as!(
-
User,
-
"SELECT * FROM users WHERE email = $1",
-
payload.email
-
)
-
.fetch_optional(&state.db)
-
.await?
+
let user = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let user = sqlx::query_as::<Postgres, User>("SELECT * FROM users WHERE email = $1")
+
.bind(&payload.email)
+
.fetch_optional(&mut *tx)
+
.await?;
+
tx.commit().await?;
+
user
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let user = sqlx::query_as::<Sqlite, User>("SELECT * FROM users WHERE email = ?")
+
.bind(&payload.email)
+
.fetch_optional(&mut *tx)
+
.await?;
+
tx.commit().await?;
+
user
+
}
+
}
.ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?;
let argon2 = Argon2::default();
-
let parsed_hash = PasswordHash::new(&user.password_hash)
-
.map_err(|e| AppError::Auth(e.to_string()))?;
+
let parsed_hash =
+
PasswordHash::new(&user.password_hash).map_err(|e| AppError::Auth(e.to_string()))?;
-
if argon2.verify_password(payload.password.as_bytes(), &parsed_hash).is_err() {
+
if argon2
+
.verify_password(payload.password.as_bytes(), &parsed_hash)
+
.is_err()
+
{
return Err(AppError::Auth("Invalid credentials".to_string()));
}
···
let token = encode(
&Header::default(),
&claims,
-
&EncodingKey::from_secret(secret.as_bytes())
-
).map_err(|e| AppError::Auth(e.to_string()))?;
+
&EncodingKey::from_secret(secret.as_bytes()),
+
)
+
.map_err(|e| AppError::Auth(e.to_string()))?;
Ok(HttpResponse::Ok().json(AuthResponse {
token,
···
email: user.email,
},
}))
-
}
+
}
+
+
pub async fn edit_link(
+
state: web::Data<AppState>,
+
user: AuthenticatedUser,
+
path: web::Path<i32>,
+
payload: web::Json<CreateLink>,
+
) -> Result<impl Responder, AppError> {
+
let link_id: i32 = path.into_inner();
+
+
// Validate the new URL if provided
+
validate_url(&payload.url)?;
+
+
// Validate custom code if provided
+
if let Some(ref custom_code) = payload.custom_code {
+
validate_custom_code(custom_code)?;
+
+
// Check if the custom code is already taken by another link
+
let existing_link = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1 AND id != $2")
+
.bind(custom_code)
+
.bind(link_id)
+
.fetch_optional(pool)
+
.await?
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = ?1 AND id != ?2")
+
.bind(custom_code)
+
.bind(link_id)
+
.fetch_optional(pool)
+
.await?
+
}
+
};
+
+
if existing_link.is_some() {
+
return Err(AppError::InvalidInput(
+
"Custom code already taken".to_string(),
+
));
+
}
+
}
+
+
// Update the link
+
let updated_link = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
+
// First verify the link belongs to the user
+
let link =
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = $1 AND user_id = $2")
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(&mut *tx)
+
.await?;
+
+
if link.is_none() {
+
return Err(AppError::NotFound);
+
}
+
+
// Update the link
+
let updated = sqlx::query_as::<_, Link>(
+
r#"
+
UPDATE links
+
SET
+
original_url = $1,
+
short_code = COALESCE($2, short_code)
+
WHERE id = $3 AND user_id = $4
+
RETURNING *
+
"#,
+
)
+
.bind(&payload.url)
+
.bind(&payload.custom_code)
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_one(&mut *tx)
+
.await?;
+
+
// If source is provided, add a click record
+
if let Some(ref source) = payload.source {
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)")
+
.bind(link_id)
+
.bind(source)
+
.execute(&mut *tx)
+
.await?;
+
}
+
+
tx.commit().await?;
+
updated
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
+
// First verify the link belongs to the user
+
let link =
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = ?1 AND user_id = ?2")
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(&mut *tx)
+
.await?;
+
+
if link.is_none() {
+
return Err(AppError::NotFound);
+
}
+
+
// Update the link
+
let updated = sqlx::query_as::<_, Link>(
+
r#"
+
UPDATE links
+
SET
+
original_url = ?1,
+
short_code = COALESCE(?2, short_code)
+
WHERE id = ?3 AND user_id = ?4
+
RETURNING *
+
"#,
+
)
+
.bind(&payload.url)
+
.bind(&payload.custom_code)
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_one(&mut *tx)
+
.await?;
+
+
// If source is provided, add a click record
+
if let Some(ref source) = payload.source {
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES (?1, ?2)")
+
.bind(link_id)
+
.bind(source)
+
.execute(&mut *tx)
+
.await?;
+
}
+
+
tx.commit().await?;
+
updated
+
}
+
};
+
+
Ok(HttpResponse::Ok().json(updated_link))
+
}
+
+
pub async fn delete_link(
+
state: web::Data<AppState>,
+
user: AuthenticatedUser,
+
path: web::Path<i32>,
+
) -> Result<impl Responder, AppError> {
+
let link_id: i32 = path.into_inner();
+
+
match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
+
// Verify the link belongs to the user
+
let link = sqlx::query_as::<Postgres, (i32,)>(
+
"SELECT id FROM links WHERE id = $1 AND user_id = $2",
+
)
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(&mut *tx)
+
.await?;
+
+
if link.is_none() {
+
return Err(AppError::NotFound);
+
}
+
+
// Delete associated clicks first due to foreign key constraint
+
sqlx::query("DELETE FROM clicks WHERE link_id = $1")
+
.bind(link_id)
+
.execute(&mut *tx)
+
.await?;
+
+
// Delete the link
+
sqlx::query("DELETE FROM links WHERE id = $1")
+
.bind(link_id)
+
.execute(&mut *tx)
+
.await?;
+
+
tx.commit().await?;
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
+
// Verify the link belongs to the user
+
let link = sqlx::query_as::<Sqlite, (i32,)>(
+
"SELECT id FROM links WHERE id = ? AND user_id = ?",
+
)
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(&mut *tx)
+
.await?;
+
+
if link.is_none() {
+
return Err(AppError::NotFound);
+
}
+
+
// Delete associated clicks first due to foreign key constraint
+
sqlx::query("DELETE FROM clicks WHERE link_id = ?")
+
.bind(link_id)
+
.execute(&mut *tx)
+
.await?;
+
+
// Delete the link
+
sqlx::query("DELETE FROM links WHERE id = ?")
+
.bind(link_id)
+
.execute(&mut *tx)
+
.await?;
+
+
tx.commit().await?;
+
}
+
}
+
+
Ok(HttpResponse::NoContent().finish())
+
}
+
+
pub async fn get_link_clicks(
+
state: web::Data<AppState>,
+
user: AuthenticatedUser,
+
path: web::Path<i32>,
+
) -> Result<impl Responder, AppError> {
+
let link_id = path.into_inner();
+
+
// First verify the link belongs to the user
+
let link = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query_as::<_, (i32,)>("SELECT id FROM links WHERE id = $1 AND user_id = $2")
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(pool)
+
.await?
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query_as::<_, (i32,)>("SELECT id FROM links WHERE id = ? AND user_id = ?")
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(pool)
+
.await?
+
}
+
};
+
+
if link.is_none() {
+
return Err(AppError::NotFound);
+
}
+
+
let clicks = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query_as::<_, ClickStats>(
+
r#"
+
SELECT
+
DATE(created_at)::text as date,
+
COUNT(*)::bigint as clicks
+
FROM clicks
+
WHERE link_id = $1
+
GROUP BY DATE(created_at)
+
ORDER BY DATE(created_at) ASC
+
"#,
+
)
+
.bind(link_id)
+
.fetch_all(pool)
+
.await?
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query_as::<_, ClickStats>(
+
r#"
+
SELECT
+
DATE(created_at) as date,
+
COUNT(*) as clicks
+
FROM clicks
+
WHERE link_id = ?
+
GROUP BY DATE(created_at)
+
ORDER BY DATE(created_at) ASC
+
"#,
+
)
+
.bind(link_id)
+
.fetch_all(pool)
+
.await?
+
}
+
};
+
+
Ok(HttpResponse::Ok().json(clicks))
+
}
+
+
pub async fn get_link_sources(
+
state: web::Data<AppState>,
+
user: AuthenticatedUser,
+
path: web::Path<i32>,
+
) -> Result<impl Responder, AppError> {
+
let link_id = path.into_inner();
+
+
// Verify the link belongs to the user
+
let link = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let link = sqlx::query_as::<Postgres, (i32,)>(
+
"SELECT id FROM links WHERE id = $1 AND user_id = $2",
+
)
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(&mut *tx)
+
.await?;
+
tx.commit().await?;
+
link
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let link = sqlx::query_as::<Sqlite, (i32,)>(
+
"SELECT id FROM links WHERE id = ? AND user_id = ?",
+
)
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(&mut *tx)
+
.await?;
+
tx.commit().await?;
+
link
+
}
+
};
+
+
if link.is_none() {
+
return Err(AppError::NotFound);
+
}
+
+
let sources = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query_as::<_, SourceStats>(
+
r#"
+
SELECT
+
DATE(created_at)::text as date,
+
query_source as source,
+
COUNT(*)::bigint as count
+
FROM clicks
+
WHERE link_id = $1
+
AND query_source IS NOT NULL
+
AND query_source != ''
+
GROUP BY DATE(created_at), query_source
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
+
"#,
+
)
+
.bind(link_id)
+
.fetch_all(pool)
+
.await?
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query_as::<_, SourceStats>(
+
r#"
+
SELECT
+
DATE(created_at) as date,
+
query_source as source,
+
COUNT(*) as count
+
FROM clicks
+
WHERE link_id = ?
+
AND query_source IS NOT NULL
+
AND query_source != ''
+
GROUP BY DATE(created_at), query_source
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
+
"#,
+
)
+
.bind(link_id)
+
.fetch_all(pool)
+
.await?
+
}
+
};
+
+
Ok(HttpResponse::Ok().json(sources))
+
}
+
+
pub async fn check_first_user(state: web::Data<AppState>) -> Result<impl Responder, AppError> {
+
let user_count = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users")
+
.fetch_one(pool)
+
.await?
+
.0
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users")
+
.fetch_one(pool)
+
.await?
+
.0
+
}
+
};
+
+
Ok(HttpResponse::Ok().json(json!({
+
"isFirstUser": user_count == 0
+
})))
+
}
+127 -2
src/lib.rs
···
-
use sqlx::PgPool;
+
use anyhow::Result;
+
use rand::Rng;
+
use sqlx::migrate::MigrateDatabase;
+
use sqlx::postgres::PgPoolOptions;
+
use sqlx::{Postgres, Sqlite};
+
use std::fs::File;
+
use std::io::Write;
+
use tracing::info;
+
+
use models::DatabasePool;
pub mod auth;
pub mod error;
···
#[derive(Clone)]
pub struct AppState {
-
pub db: PgPool,
+
pub db: DatabasePool,
+
pub admin_token: Option<String>,
+
}
+
+
pub async fn create_db_pool() -> Result<DatabasePool> {
+
let database_url = std::env::var("DATABASE_URL").ok();
+
+
match database_url {
+
Some(url) if url.starts_with("postgres://") || url.starts_with("postgresql://") => {
+
info!("Using PostgreSQL database");
+
let pool = PgPoolOptions::new()
+
.max_connections(5)
+
.acquire_timeout(std::time::Duration::from_secs(3))
+
.connect(&url)
+
.await?;
+
+
Ok(DatabasePool::Postgres(pool))
+
}
+
_ => {
+
info!("No PostgreSQL connection string found, using SQLite");
+
+
// Get the project root directory
+
let project_root = std::env::current_dir()?;
+
let data_dir = project_root.join("data");
+
+
// Create a data directory if it doesn't exist
+
if !data_dir.exists() {
+
std::fs::create_dir_all(&data_dir)?;
+
}
+
+
let db_path = data_dir.join("simplelink.db");
+
let sqlite_url = format!("sqlite://{}", db_path.display());
+
+
// Check if database exists and create it if it doesn't
+
if !Sqlite::database_exists(&sqlite_url).await.unwrap_or(false) {
+
info!("Creating new SQLite database at {}", db_path.display());
+
Sqlite::create_database(&sqlite_url).await?;
+
info!("Database created successfully");
+
} else {
+
info!("Database already exists");
+
}
+
+
let pool = sqlx::sqlite::SqlitePoolOptions::new()
+
.max_connections(5)
+
.connect(&sqlite_url)
+
.await?;
+
+
Ok(DatabasePool::Sqlite(pool))
+
}
+
}
+
}
+
+
pub async fn run_migrations(pool: &DatabasePool) -> Result<()> {
+
match pool {
+
DatabasePool::Postgres(pool) => {
+
// Use the root migrations directory for postgres
+
sqlx::migrate!().run(pool).await?;
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::migrate!("./migrations/sqlite").run(pool).await?;
+
}
+
}
+
Ok(())
+
}
+
+
pub async fn check_and_generate_admin_token(db: &DatabasePool) -> anyhow::Result<Option<String>> {
+
// Check if any users exist
+
let user_count = match db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let count = sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users")
+
.fetch_one(&mut *tx)
+
.await?
+
.0;
+
tx.commit().await?;
+
count
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let count = sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users")
+
.fetch_one(&mut *tx)
+
.await?
+
.0;
+
tx.commit().await?;
+
count
+
}
+
};
+
+
if user_count == 0 {
+
let token: String = (0..32)
+
.map(|_| {
+
let idx = rand::thread_rng().gen_range(0..62);
+
match idx {
+
0..=9 => (b'0' + idx as u8) as char,
+
10..=35 => (b'a' + (idx - 10) as u8) as char,
+
_ => (b'A' + (idx - 36) as u8) as char,
+
}
+
})
+
.collect();
+
+
// Get the project root directory
+
let project_root = std::env::current_dir()?;
+
let token_path = project_root.join("admin-setup-token.txt");
+
+
// Save token to file
+
let mut file = File::create(token_path)?;
+
writeln!(file, "{}", token)?;
+
+
info!("No users found - generated admin setup token");
+
info!("Token has been saved to admin-setup-token.txt");
+
info!("Use this token to create the admin user");
+
info!("Admin setup token: {}", token);
+
+
Ok(Some(token))
+
} else {
+
Ok(None)
+
}
}
+211 -22
src/main.rs
···
-
use actix_web::{web, App, HttpServer};
use actix_cors::Cors;
+
use actix_web::{web, App, HttpResponse, HttpServer};
use anyhow::Result;
-
use sqlx::postgres::PgPoolOptions;
-
use simple_link::{AppState, handlers};
-
use tracing::info;
+
use clap::Parser;
+
use rust_embed::RustEmbed;
+
use simplelink::check_and_generate_admin_token;
+
use simplelink::models::DatabasePool;
+
use simplelink::{create_db_pool, run_migrations};
+
use simplelink::{handlers, AppState};
+
use sqlx::{Postgres, Sqlite};
+
use tracing::{error, info};
+
+
#[derive(Parser, Debug)]
+
#[command(author, version, about, long_about = None)]
+
#[derive(RustEmbed)]
+
#[folder = "static/"]
+
struct Asset;
+
+
async fn serve_static_file(path: &str) -> HttpResponse {
+
match Asset::get(path) {
+
Some(content) => {
+
let mime = mime_guess::from_path(path).first_or_octet_stream();
+
HttpResponse::Ok()
+
.content_type(mime.as_ref())
+
.body(content.data.into_owned())
+
}
+
None => HttpResponse::NotFound().body("404 Not Found"),
+
}
+
}
+
+
async fn create_initial_links(pool: &DatabasePool) -> Result<()> {
+
if let Ok(links) = std::env::var("INITIAL_LINKS") {
+
for link_entry in links.split(';') {
+
let parts: Vec<&str> = link_entry.split(',').collect();
+
if parts.len() >= 2 {
+
let url = parts[0];
+
let code = parts[1];
+
+
match pool {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query(
+
"INSERT INTO links (original_url, short_code, user_id)
+
VALUES ($1, $2, $3)
+
ON CONFLICT (short_code)
+
DO UPDATE SET short_code = EXCLUDED.short_code
+
WHERE links.original_url = EXCLUDED.original_url",
+
)
+
.bind(url)
+
.bind(code)
+
.bind(1)
+
.execute(pool)
+
.await?;
+
}
+
DatabasePool::Sqlite(pool) => {
+
// First check if the exact combination exists
+
let exists = sqlx::query_scalar::<_, bool>(
+
"SELECT EXISTS(
+
SELECT 1 FROM links
+
WHERE original_url = ?1
+
AND short_code = ?2
+
)",
+
)
+
.bind(url)
+
.bind(code)
+
.fetch_one(pool)
+
.await?;
+
+
// Only insert if the exact combination doesn't exist
+
if !exists {
+
sqlx::query(
+
"INSERT INTO links (original_url, short_code, user_id)
+
VALUES (?1, ?2, ?3)",
+
)
+
.bind(url)
+
.bind(code)
+
.bind(1)
+
.execute(pool)
+
.await?;
+
info!("Created initial link: {} -> {} for user_id: 1", code, url);
+
} else {
+
info!("Skipped existing link: {} -> {} for user_id: 1", code, url);
+
}
+
}
+
}
+
}
+
}
+
}
+
Ok(())
+
}
+
+
async fn create_admin_user(pool: &DatabasePool, email: &str, password: &str) -> Result<()> {
+
use argon2::{
+
password_hash::{rand_core::OsRng, SaltString},
+
Argon2, PasswordHasher,
+
};
+
+
let salt = SaltString::generate(&mut OsRng);
+
let argon2 = Argon2::default();
+
let password_hash = argon2
+
.hash_password(password.as_bytes(), &salt)
+
.map_err(|e| anyhow::anyhow!("Password hashing error: {}", e))?
+
.to_string();
+
+
match pool {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query(
+
"INSERT INTO users (email, password_hash)
+
VALUES ($1, $2)
+
ON CONFLICT (email) DO NOTHING",
+
)
+
.bind(email)
+
.bind(&password_hash)
+
.execute(pool)
+
.await?;
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query(
+
"INSERT OR IGNORE INTO users (email, password_hash)
+
VALUES (?1, ?2)",
+
)
+
.bind(email)
+
.bind(&password_hash)
+
.execute(pool)
+
.await?;
+
}
+
}
+
info!("Created admin user: {}", email);
+
Ok(())
+
}
#[actix_web::main]
async fn main() -> Result<()> {
···
// Initialize logging
tracing_subscriber::fmt::init();
-
// Database connection string from environment
-
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
-
// Create database connection pool
-
let pool = PgPoolOptions::new()
-
.max_connections(5)
-
.acquire_timeout(std::time::Duration::from_secs(3))
-
.connect(&database_url)
-
.await?;
+
let pool = create_db_pool().await?;
+
run_migrations(&pool).await?;
-
// Run database migrations
-
sqlx::migrate!("./migrations").run(&pool).await?;
+
// First check if admin credentials are provided in environment variables
+
let admin_credentials = match (
+
std::env::var("SIMPLELINK_USER"),
+
std::env::var("SIMPLELINK_PASS"),
+
) {
+
(Ok(user), Ok(pass)) => Some((user, pass)),
+
_ => None,
+
};
-
let state = AppState { db: pool };
+
if let Some((email, password)) = admin_credentials {
+
// Now check for existing users
+
let user_count = match &pool {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let count =
+
sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users")
+
.fetch_one(&mut *tx)
+
.await?
+
.0;
+
tx.commit().await?;
+
count
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let count = sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users")
+
.fetch_one(&mut *tx)
+
.await?
+
.0;
+
tx.commit().await?;
+
count
+
}
+
};
-
info!("Starting server at http://127.0.0.1:8080");
+
if user_count == 0 {
+
info!("No users found, creating admin user: {}", email);
+
match create_admin_user(&pool, &email, &password).await {
+
Ok(_) => info!("Successfully created admin user"),
+
Err(e) => {
+
error!("Failed to create admin user: {}", e);
+
return Err(anyhow::anyhow!("Failed to create admin user: {}", e));
+
}
+
}
+
}
+
} else {
+
info!(
+
"No admin credentials provided in environment variables, skipping admin user creation"
+
);
+
}
+
+
// Create initial links from environment variables
+
create_initial_links(&pool).await?;
+
+
let admin_token = check_and_generate_admin_token(&pool).await?;
+
+
let state = AppState {
+
db: pool,
+
admin_token,
+
};
+
+
let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
+
let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8080".to_string());
+
info!("Starting server at http://{}:{}", host, port);
// Start HTTP server
HttpServer::new(move || {
···
.allow_any_method()
.allow_any_header()
.max_age(3600);
-
+
App::new()
.wrap(cors)
.app_data(web::Data::new(state.clone()))
···
web::scope("/api")
.route("/shorten", web::post().to(handlers::create_short_url))
.route("/links", web::get().to(handlers::get_all_links))
+
.route("/links/{id}", web::delete().to(handlers::delete_link))
+
.route(
+
"/links/{id}/clicks",
+
web::get().to(handlers::get_link_clicks),
+
)
+
.route(
+
"/links/{id}/sources",
+
web::get().to(handlers::get_link_sources),
+
)
+
.route("/links/{id}", web::patch().to(handlers::edit_link))
.route("/auth/register", web::post().to(handlers::register))
.route("/auth/login", web::post().to(handlers::login))
+
.route(
+
"/auth/check-first-user",
+
web::get().to(handlers::check_first_user),
+
)
.route("/health", web::get().to(handlers::health_check)),
)
-
.service(
-
web::resource("/{short_code}")
-
.route(web::get().to(handlers::redirect_to_url))
-
)
+
.service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url)))
+
.default_service(web::route().to(|req: actix_web::HttpRequest| async move {
+
let path = req.path().trim_start_matches('/');
+
let path = if path.is_empty() { "index.html" } else { path };
+
serve_static_file(path).await
+
}))
})
.workers(2)
.backlog(10_000)
-
.bind("127.0.0.1:8080")?
+
.bind(format!("{}:{}", host, port))?
.run()
.await?;
+92 -8
src/models.rs
···
+
use anyhow::Result;
+
use futures::future::BoxFuture;
+
use serde::{Deserialize, Serialize};
+
use sqlx::postgres::PgRow;
+
use sqlx::sqlite::SqliteRow;
+
use sqlx::FromRow;
+
use sqlx::Pool;
+
use sqlx::Postgres;
+
use sqlx::Sqlite;
+
use sqlx::Transaction;
use std::time::{SystemTime, UNIX_EPOCH};
-
use serde::{Deserialize, Serialize};
-
use sqlx::FromRow;
+
#[derive(Clone)]
+
pub enum DatabasePool {
+
Postgres(Pool<Postgres>),
+
Sqlite(Pool<Sqlite>),
+
}
+
+
impl DatabasePool {
+
pub async fn begin(&self) -> Result<Box<dyn std::any::Any + Send>> {
+
match self {
+
DatabasePool::Postgres(pool) => Ok(Box::new(pool.begin().await?)),
+
DatabasePool::Sqlite(pool) => Ok(Box::new(pool.begin().await?)),
+
}
+
}
+
+
pub async fn fetch_optional<T>(&self, pg_query: &str, sqlite_query: &str) -> Result<Option<T>>
+
where
+
T: for<'r> FromRow<'r, PgRow> + for<'r> FromRow<'r, SqliteRow> + Send + Sync + Unpin,
+
{
+
match self {
+
DatabasePool::Postgres(pool) => {
+
Ok(sqlx::query_as(pg_query).fetch_optional(pool).await?)
+
}
+
DatabasePool::Sqlite(pool) => {
+
Ok(sqlx::query_as(sqlite_query).fetch_optional(pool).await?)
+
}
+
}
+
}
+
+
pub async fn execute(&self, pg_query: &str, sqlite_query: &str) -> Result<()> {
+
match self {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query(pg_query).execute(pool).await?;
+
Ok(())
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query(sqlite_query).execute(pool).await?;
+
Ok(())
+
}
+
}
+
}
+
+
pub async fn transaction<'a, F, R>(&'a self, f: F) -> Result<R>
+
where
+
F: for<'c> Fn(&'c mut Transaction<'_, Postgres>) -> BoxFuture<'c, Result<R>>
+
+ for<'c> Fn(&'c mut Transaction<'_, Sqlite>) -> BoxFuture<'c, Result<R>>
+
+ Copy,
+
R: Send + 'static,
+
{
+
match self {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let result = f(&mut tx).await?;
+
tx.commit().await?;
+
Ok(result)
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let result = f(&mut tx).await?;
+
tx.commit().await?;
+
Ok(result)
+
}
+
}
+
}
+
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
···
let exp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
-
.as_secs() as usize + 24 * 60 * 60; // 24 hours from now
-
-
Self {
-
sub: user_id,
-
exp,
-
}
+
.as_secs() as usize
+
+ 14 * 24 * 60 * 60; // 2 weeks from now
+
+
Self { sub: user_id, exp }
}
}
···
pub struct RegisterRequest {
pub email: String,
pub password: String,
+
pub admin_token: Option<String>,
}
#[derive(Serialize)]
···
pub email: String,
pub password_hash: String,
}
+
+
#[derive(sqlx::FromRow, Serialize)]
+
pub struct ClickStats {
+
pub date: String,
+
pub clicks: i64,
+
}
+
+
#[derive(sqlx::FromRow, Serialize)]
+
pub struct SourceStats {
+
pub date: String,
+
pub source: String,
+
pub count: i64,
+
}
+175
test/.gitignore
···
+
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
+
+
# Logs
+
+
logs
+
_.log
+
npm-debug.log_
+
yarn-debug.log*
+
yarn-error.log*
+
lerna-debug.log*
+
.pnpm-debug.log*
+
+
# Caches
+
+
.cache
+
+
# Diagnostic reports (https://nodejs.org/api/report.html)
+
+
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+
# Runtime data
+
+
pids
+
_.pid
+
_.seed
+
*.pid.lock
+
+
# Directory for instrumented libs generated by jscoverage/JSCover
+
+
lib-cov
+
+
# Coverage directory used by tools like istanbul
+
+
coverage
+
*.lcov
+
+
# nyc test coverage
+
+
.nyc_output
+
+
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+
+
.grunt
+
+
# Bower dependency directory (https://bower.io/)
+
+
bower_components
+
+
# node-waf configuration
+
+
.lock-wscript
+
+
# Compiled binary addons (https://nodejs.org/api/addons.html)
+
+
build/Release
+
+
# Dependency directories
+
+
node_modules/
+
jspm_packages/
+
+
# Snowpack dependency directory (https://snowpack.dev/)
+
+
web_modules/
+
+
# TypeScript cache
+
+
*.tsbuildinfo
+
+
# Optional npm cache directory
+
+
.npm
+
+
# Optional eslint cache
+
+
.eslintcache
+
+
# Optional stylelint cache
+
+
.stylelintcache
+
+
# Microbundle cache
+
+
.rpt2_cache/
+
.rts2_cache_cjs/
+
.rts2_cache_es/
+
.rts2_cache_umd/
+
+
# Optional REPL history
+
+
.node_repl_history
+
+
# Output of 'npm pack'
+
+
*.tgz
+
+
# Yarn Integrity file
+
+
.yarn-integrity
+
+
# dotenv environment variable files
+
+
.env
+
.env.development.local
+
.env.test.local
+
.env.production.local
+
.env.local
+
+
# parcel-bundler cache (https://parceljs.org/)
+
+
.parcel-cache
+
+
# Next.js build output
+
+
.next
+
out
+
+
# Nuxt.js build / generate output
+
+
.nuxt
+
dist
+
+
# Gatsby files
+
+
# Comment in the public line in if your project uses Gatsby and not Next.js
+
+
# https://nextjs.org/blog/next-9-1#public-directory-support
+
+
# public
+
+
# vuepress build output
+
+
.vuepress/dist
+
+
# vuepress v2.x temp and cache directory
+
+
.temp
+
+
# Docusaurus cache and generated files
+
+
.docusaurus
+
+
# Serverless directories
+
+
.serverless/
+
+
# FuseBox cache
+
+
.fusebox/
+
+
# DynamoDB Local files
+
+
.dynamodb/
+
+
# TernJS port file
+
+
.tern-port
+
+
# Stores VSCode versions used for testing VSCode extensions
+
+
.vscode-test
+
+
# yarn v2
+
+
.yarn/cache
+
.yarn/unplugged
+
.yarn/build-state.yml
+
.yarn/install-state.gz
+
.pnp.*
+
+
# IntelliJ based IDEs
+
.idea
+
+
# Finder (MacOS) folder config
+
.DS_Store
+15
test/README.md
···
+
# test
+
+
To install dependencies:
+
+
```bash
+
bun install
+
```
+
+
To run:
+
+
```bash
+
bun run index.ts
+
```
+
+
This project was created using `bun init` in bun v1.2.0. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
+32
test/bun.lock
···
+
{
+
"lockfileVersion": 1,
+
"workspaces": {
+
"": {
+
"name": "test",
+
"dependencies": {
+
"k6": "^0.0.0",
+
},
+
"devDependencies": {
+
"@types/bun": "latest",
+
},
+
"peerDependencies": {
+
"typescript": "^5.0.0",
+
},
+
},
+
},
+
"packages": {
+
"@types/bun": ["@types/bun@1.2.0", "", { "dependencies": { "bun-types": "1.2.0" } }, "sha512-5N1JqdahfpBlAv4wy6svEYcd/YfO2GNrbL95JOmFx8nkE6dbK4R0oSE5SpBA4vBRqgrOUAXF8Dpiz+gi7r80SA=="],
+
+
"@types/node": ["@types/node@22.10.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww=="],
+
+
"@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="],
+
+
"bun-types": ["bun-types@1.2.0", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-KEaJxyZfbV/c4eyG0vyehDpYmBGreNiQbZIqvVHJwZ4BmeuWlNZ7EAzMN2Zcd7ailmS/tGVW0BgYbGf+lGEpWw=="],
+
+
"k6": ["k6@0.0.0", "", {}, "sha512-GAQSWayS2+LjbH5bkRi+pMPYyP1JSp7o+4j58ANZ762N/RH/SdlAT3CHHztnn8s/xgg8kYNM24Gd2IPo9b5W+g=="],
+
+
"typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
+
+
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
+
}
+
}
+1
test/index.ts
···
+
console.log("Hello via Bun!");
+39
test/mikubeam.js
···
+
import http from "k6/http";
+
import { check, sleep } from "k6";
+
+
// Test configuration
+
export const options = {
+
stages: [
+
{ duration: "30s", target: 50 }, // Ramp up to 50 users
+
{ duration: "1m", target: 50 }, // Stay at 50 users for 1 minute
+
{ duration: "30s", target: 100 }, // Ramp up to 100 users
+
{ duration: "1m", target: 100 }, // Stay at 100 users for 1 minute
+
{ duration: "30s", target: 0 }, // Ramp down to 0 users
+
],
+
thresholds: {
+
http_req_duration: ["p(95)<500"], // 95% of requests should be below 500ms
+
"checks{type:redirect}": ["rate>0.95"], // 95% success rate
+
},
+
};
+
+
const SHORTENED_URL = "http://localhost:8080/mikubeam";
+
+
export default function () {
+
const res = http.get(SHORTENED_URL, {
+
tags: { type: "redirect" },
+
redirects: 0, // Don't follow redirects to measure just the redirect response
+
});
+
+
// Check if we got a redirect status (307)
+
check(
+
res,
+
{
+
"status is 307": (r) => r.status === 307,
+
"has location header": (r) => r.headers["Location"] !== undefined,
+
},
+
{ type: "redirect" }
+
);
+
+
sleep(1); // Add some think time between requests
+
}
+
+14
test/package.json
···
+
{
+
"name": "test",
+
"module": "index.ts",
+
"type": "module",
+
"devDependencies": {
+
"@types/bun": "latest"
+
},
+
"peerDependencies": {
+
"typescript": "^5.0.0"
+
},
+
"dependencies": {
+
"k6": "^0.0.0"
+
}
+
}
+27
test/tsconfig.json
···
+
{
+
"compilerOptions": {
+
// Enable latest features
+
"lib": ["ESNext", "DOM"],
+
"target": "ESNext",
+
"module": "ESNext",
+
"moduleDetection": "force",
+
"jsx": "react-jsx",
+
"allowJs": true,
+
+
// Bundler mode
+
"moduleResolution": "bundler",
+
"allowImportingTsExtensions": true,
+
"verbatimModuleSyntax": true,
+
"noEmit": true,
+
+
// Best practices
+
"strict": true,
+
"skipLibCheck": true,
+
"noFallthroughCasesInSwitch": true,
+
+
// Some stricter flags (disabled by default)
+
"noUnusedLocals": false,
+
"noUnusedParameters": false,
+
"noPropertyAccessFromIndexSignature": false
+
}
+
}