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.

+2 -1
.env.example
···
-
DATABASE_URL=postgresql://user:password@localhost/dbname
+
# 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
+5 -15
.github/workflows/docker-image.yml
···
- name: Install cosign
if: github.event_name != 'pull_request'
-
uses: sigstore/cosign-installer@v3.7.0
+
uses: sigstore/cosign-installer@v3.8.1
with:
-
cosign-release: "v2.4.1"
+
cosign-release: "v2.4.3"
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
···
${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ github.repository }}
-
- name: Build and push Docker image (amd64)
-
uses: docker/build-push-action@v6
-
with:
-
context: .
-
file: ./Dockerfile
-
platforms: linux/amd64
-
push: ${{ github.event_name != 'pull_request' }}
-
tags: ${{ steps.meta.outputs.tags }}-amd64
-
labels: ${{ steps.meta.outputs.labels }}
-
-
- name: Build and push Docker image (arm64)
+
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
-
platforms: linux/arm64
+
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
-
tags: ${{ steps.meta.outputs.tags }}-arm64
+
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-80
.github/workflows/main.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: macos-latest
-
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.7.0
-
with:
-
cosign-release: "v2.4.1"
-
-
- 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 (amd64)
-
uses: docker/build-push-action@v6
-
with:
-
context: .
-
file: ./Dockerfile
-
platforms: linux/amd64
-
push: ${{ github.event_name != 'pull_request' }}
-
tags: ${{ steps.meta.outputs.tags }}-amd64
-
labels: ${{ steps.meta.outputs.labels }}
-
-
- name: Build and push Docker image (arm64)
-
uses: docker/build-push-action@v6
-
with:
-
context: .
-
file: ./Dockerfile
-
platforms: linux/arm64
-
push: ${{ github.event_name != 'pull_request' }}
-
tags: ${{ steps.meta.outputs.tags }}-arm64
-
labels: ${{ steps.meta.outputs.labels }}
+1
.gitignore
···
admin-setup-token.txt
package-lock.json
bun.lock
+
*.db*
+67 -230
Cargo.lock
···
]
[[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"
···
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
-
name = "foreign-types"
-
version = "0.3.2"
-
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"
+
name = "foldhash"
+
version = "0.1.4"
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.9.1"
+
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
-
"hashbrown 0.14.5",
+
"hashbrown",
[[package]]
···
checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652"
dependencies = [
"equivalent",
-
"hashbrown 0.15.2",
+
"hashbrown",
[[package]]
···
[[package]]
-
name = "minimal-lexical"
-
version = "0.2.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
-
-
[[package]]
name = "miniz_oxide"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[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",
-
]
-
-
[[package]]
-
name = "openssl-probe"
-
version = "0.1.6"
-
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"
-
-
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[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 = "schannel"
-
version = "0.1.27"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
-
dependencies = [
-
"windows-sys 0.59.0",
-
]
-
-
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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"
···
"chrono",
"clap",
"dotenv",
+
"futures",
"jsonwebtoken",
"lazy_static",
"mime_guess",
···
[[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.8.1"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "fcfa89bea9500db4a0d038513d7a060566bfc51d46d1c014847049a45cce85e8"
+
checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f"
dependencies = [
"sqlx-core",
"sqlx-macros",
···
[[package]]
name = "sqlx-core"
-
version = "0.8.1"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d06e2f2bd861719b1f3f0c7dbe1d80c30bf59e76cf019f07d9014ed7eefb8e08"
+
checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0"
dependencies = [
-
"atoi",
-
"byteorder",
"bytes",
"chrono",
"crc",
"crossbeam-queue",
"either",
"event-listener",
-
"futures-channel",
"futures-core",
"futures-intrusive",
"futures-io",
"futures-util",
-
"hashbrown 0.14.5",
+
"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.8.1"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "2f998a9defdbd48ed005a89362bd40dd2117502f15294f61c8d47034107dbbdc"
+
checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310"
dependencies = [
"proc-macro2",
"quote",
···
[[package]]
name = "sqlx-macros-core"
-
version = "0.8.1"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3d100558134176a2629d46cec0c8891ba0be8910f7896abfdb75ef4ab6f4e7ce"
+
checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad"
dependencies = [
"dotenvy",
"either",
···
[[package]]
name = "sqlx-mysql"
-
version = "0.8.1"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "936cac0ab331b14cb3921c62156d913e4c15b74fb6ec0f3146bd4ef6e4fb3c12"
+
checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233"
dependencies = [
"atoi",
"base64 0.22.1",
···
"smallvec",
"sqlx-core",
"stringprep",
-
"thiserror 1.0.69",
+
"thiserror 2.0.11",
"tracing",
-
"uuid",
"whoami",
[[package]]
name = "sqlx-postgres"
-
version = "0.8.1"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "9734dbce698c67ecf67c442f768a5e90a49b2a4d61a9f1d59f73874bd4cf0710"
+
checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613"
dependencies = [
"atoi",
"base64 0.22.1",
···
"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.8.1"
+
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a75b419c3c1b1697833dd927bdc4c6545a620bc1bbafabd44e1efbe9afcd337e"
+
checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540"
dependencies = [
"atoi",
"chrono",
···
"sqlx-core",
"tracing",
"url",
-
"uuid",
[[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",
···
[[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",
···
checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
[[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"
···
checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b"
dependencies = [
"getrandom",
-
"serde",
[[package]]
···
[[package]]
-
name = "winapi"
-
version = "0.3.9"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-
dependencies = [
-
"winapi-i686-pc-windows-gnu",
-
"winapi-x86_64-pc-windows-gnu",
-
]
-
-
[[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-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
dependencies = [
"windows-sys 0.59.0",
-
-
[[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"
+4 -3
Cargo.toml
···
actix-web = "4.4"
actix-files = "0.6"
actix-cors = "0.6"
-
tokio = { version = "1.36", features = ["full"] }
-
sqlx = { version = "0.8", 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"
···
argon2 = "0.5.3"
rand = { version = "0.8", features = ["std"] }
mime_guess = "2.0.5"
+
futures = "0.3.31"
+2 -2
Dockerfile
···
WORKDIR /usr/src/frontend
# Copy frontend files
-
COPY frontend/package*.json ./
+
COPY frontend/package.json ./
RUN bun install
COPY frontend/ ./
···
# Copy static files
COPY --from=backend-builder /usr/src/app/static /app/static
-
# Expose the port (this is just documentation)
+
# Expose the port
EXPOSE 8080
# Set default network configuration
+52 -12
README.md
···
# SimpleLink
-
A very performant and light (6mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres.
+
+
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
-
The project will not run withot DATABASE_URL set. (TODO add sqlite support)
+
First configure .env.example and save it to .env
```bash
-
#set api-domain to where you will be deploying the link shortener, eg: link.example.com, default is localhost:8080
git clone https://github.com/waveringana/simplelink && cd simplelink
-
./build.sh api-domain=localhost:8080
+
./build.sh
cargo run
```
-
On an empty database, an admin-setup-token.txt is created as well as pasted into the terminal output. This is needed to make the admin account.
+
Alternatively for a binary build:
-
Alternatively if you want a binary form
```bash
./build.sh --binary
```
+
then check /target/release for the binary named `SimpleGit`
### From Docker
+
```bash
-
docker build --build-arg API_URL=http://localhost:8080 -t simplelink .
-
docker run simplelink -p 8080:8080 \
-
-e JWT_SECRET=change-me-in-production \
-
-e DATABASE_URL=postgres://user:password@host:port/database \
+
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.
+
+
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
+7 -7
build.sh
···
#!/bin/bash
# Default values
-
API_URL="http://localhost:8080"
+
#API_URL="http://localhost:8080"
RELEASE_MODE=false
BINARY_MODE=false
···
for arg in "$@"
do
case $arg in
-
api-domain=*)
-
API_URL="${arg#*=}"
-
shift
-
;;
+
#api-domain=*)
+
#API_URL="${arg#*=}"
+
#shift
+
#;;
--release)
RELEASE_MODE=true
shift
···
esac
done
-
echo "Building project with API_URL: $API_URL"
+
#echo "Building project with API_URL: $API_URL"
echo "Release mode: $RELEASE_MODE"
# Check if cargo is installed
···
# Build frontend
echo "Building frontend..."
# Create .env file for Vite
-
echo "VITE_API_URL=$API_URL" > frontend/.env
+
#echo "VITE_API_URL=$API_URL" > frontend/.env
# Install frontend dependencies and build
cd frontend
+1 -5
docker-compose.yml
···
- shortener-network
app:
-
build:
-
context: .
-
dockerfile: Dockerfile
-
args:
-
- API_URL=${API_URL:-http://localhost:3000}
+
image: ghcr.io/waveringana/simplelink:v2.2
container_name: shortener-app
ports:
- "8080:8080"
+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>
-3996
frontend/package-lock.json
···
-
{
-
"name": "frontend",
-
"version": "0.0.0",
-
"lockfileVersion": 3,
-
"requires": true,
-
"packages": {
-
"": {
-
"name": "frontend",
-
"version": "0.0.0",
-
"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-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",
-
"@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",
-
"postcss": "^8.5.1",
-
"tailwindcss": "^4.0.0",
-
"typescript": "~5.6.2",
-
"typescript-eslint": "^8.18.2",
-
"vite": "^6.0.5"
-
}
-
},
-
"node_modules/@alloc/quick-lru": {
-
"version": "5.2.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=10"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/@ampproject/remapping": {
-
"version": "2.3.0",
-
"dev": true,
-
"license": "Apache-2.0",
-
"dependencies": {
-
"@jridgewell/gen-mapping": "^0.3.5",
-
"@jridgewell/trace-mapping": "^0.3.24"
-
},
-
"engines": {
-
"node": ">=6.0.0"
-
}
-
},
-
"node_modules/@babel/code-frame": {
-
"version": "7.26.2",
-
"license": "MIT",
-
"dependencies": {
-
"@babel/helper-validator-identifier": "^7.25.9",
-
"js-tokens": "^4.0.0",
-
"picocolors": "^1.0.0"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/compat-data": {
-
"version": "7.26.5",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/core": {
-
"version": "7.26.7",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/babel"
-
}
-
},
-
"node_modules/@babel/generator": {
-
"version": "7.26.5",
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/helper-compilation-targets": {
-
"version": "7.26.5",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/helper-module-imports": {
-
"version": "7.25.9",
-
"license": "MIT",
-
"dependencies": {
-
"@babel/traverse": "^7.25.9",
-
"@babel/types": "^7.25.9"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/helper-module-transforms": {
-
"version": "7.26.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@babel/helper-module-imports": "^7.25.9",
-
"@babel/helper-validator-identifier": "^7.25.9",
-
"@babel/traverse": "^7.25.9"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
},
-
"peerDependencies": {
-
"@babel/core": "^7.0.0"
-
}
-
},
-
"node_modules/@babel/helper-plugin-utils": {
-
"version": "7.26.5",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/helper-string-parser": {
-
"version": "7.25.9",
-
"license": "MIT",
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/helper-validator-identifier": {
-
"version": "7.25.9",
-
"license": "MIT",
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/helper-validator-option": {
-
"version": "7.25.9",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/helpers": {
-
"version": "7.26.7",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@babel/template": "^7.25.9",
-
"@babel/types": "^7.26.7"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/parser": {
-
"version": "7.26.7",
-
"license": "MIT",
-
"dependencies": {
-
"@babel/types": "^7.26.7"
-
},
-
"bin": {
-
"parser": "bin/babel-parser.js"
-
},
-
"engines": {
-
"node": ">=6.0.0"
-
}
-
},
-
"node_modules/@babel/plugin-transform-react-jsx-self": {
-
"version": "7.25.9",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@babel/helper-plugin-utils": "^7.25.9"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
},
-
"peerDependencies": {
-
"@babel/core": "^7.0.0-0"
-
}
-
},
-
"node_modules/@babel/plugin-transform-react-jsx-source": {
-
"version": "7.25.9",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@babel/helper-plugin-utils": "^7.25.9"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
},
-
"peerDependencies": {
-
"@babel/core": "^7.0.0-0"
-
}
-
},
-
"node_modules/@babel/runtime": {
-
"version": "7.26.7",
-
"license": "MIT",
-
"dependencies": {
-
"regenerator-runtime": "^0.14.0"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/template": {
-
"version": "7.25.9",
-
"license": "MIT",
-
"dependencies": {
-
"@babel/code-frame": "^7.25.9",
-
"@babel/parser": "^7.25.9",
-
"@babel/types": "^7.25.9"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/traverse": {
-
"version": "7.26.7",
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@babel/traverse/node_modules/globals": {
-
"version": "11.12.0",
-
"license": "MIT",
-
"engines": {
-
"node": ">=4"
-
}
-
},
-
"node_modules/@babel/types": {
-
"version": "7.26.7",
-
"license": "MIT",
-
"dependencies": {
-
"@babel/helper-string-parser": "^7.25.9",
-
"@babel/helper-validator-identifier": "^7.25.9"
-
},
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/@emotion/babel-plugin": {
-
"version": "11.13.5",
-
"license": "MIT",
-
"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"
-
}
-
},
-
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
-
"version": "1.9.0",
-
"license": "MIT"
-
},
-
"node_modules/@emotion/cache": {
-
"version": "11.14.0",
-
"license": "MIT",
-
"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"
-
}
-
},
-
"node_modules/@emotion/hash": {
-
"version": "0.9.2",
-
"license": "MIT"
-
},
-
"node_modules/@emotion/memoize": {
-
"version": "0.9.0",
-
"license": "MIT"
-
},
-
"node_modules/@emotion/react": {
-
"version": "11.14.0",
-
"license": "MIT",
-
"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"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@emotion/serialize": {
-
"version": "1.3.3",
-
"license": "MIT",
-
"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"
-
}
-
},
-
"node_modules/@emotion/sheet": {
-
"version": "1.4.0",
-
"license": "MIT"
-
},
-
"node_modules/@emotion/unitless": {
-
"version": "0.10.0",
-
"license": "MIT"
-
},
-
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
-
"version": "1.2.0",
-
"license": "MIT",
-
"peerDependencies": {
-
"react": ">=16.8.0"
-
}
-
},
-
"node_modules/@emotion/utils": {
-
"version": "1.4.2",
-
"license": "MIT"
-
},
-
"node_modules/@emotion/weak-memoize": {
-
"version": "0.4.0",
-
"license": "MIT"
-
},
-
"node_modules/@esbuild/darwin-arm64": {
-
"version": "0.24.2",
-
"cpu": [
-
"arm64"
-
],
-
"license": "MIT",
-
"optional": true,
-
"os": [
-
"darwin"
-
],
-
"engines": {
-
"node": ">=18"
-
}
-
},
-
"node_modules/@eslint-community/eslint-utils": {
-
"version": "4.4.1",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"eslint-visitor-keys": "^3.4.3"
-
},
-
"engines": {
-
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
-
},
-
"funding": {
-
"url": "https://opencollective.com/eslint"
-
},
-
"peerDependencies": {
-
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
-
}
-
},
-
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
-
"version": "3.4.3",
-
"dev": true,
-
"license": "Apache-2.0",
-
"engines": {
-
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
-
},
-
"funding": {
-
"url": "https://opencollective.com/eslint"
-
}
-
},
-
"node_modules/@eslint-community/regexpp": {
-
"version": "4.12.1",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
-
}
-
},
-
"node_modules/@eslint/config-array": {
-
"version": "0.19.1",
-
"dev": true,
-
"license": "Apache-2.0",
-
"dependencies": {
-
"@eslint/object-schema": "^2.1.5",
-
"debug": "^4.3.1",
-
"minimatch": "^3.1.2"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
}
-
},
-
"node_modules/@eslint/core": {
-
"version": "0.10.0",
-
"dev": true,
-
"license": "Apache-2.0",
-
"dependencies": {
-
"@types/json-schema": "^7.0.15"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
}
-
},
-
"node_modules/@eslint/eslintrc": {
-
"version": "3.2.0",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"url": "https://opencollective.com/eslint"
-
}
-
},
-
"node_modules/@eslint/eslintrc/node_modules/globals": {
-
"version": "14.0.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=18"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/@eslint/js": {
-
"version": "9.19.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
}
-
},
-
"node_modules/@eslint/object-schema": {
-
"version": "2.1.5",
-
"dev": true,
-
"license": "Apache-2.0",
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
}
-
},
-
"node_modules/@eslint/plugin-kit": {
-
"version": "0.2.5",
-
"dev": true,
-
"license": "Apache-2.0",
-
"dependencies": {
-
"@eslint/core": "^0.10.0",
-
"levn": "^0.4.1"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
}
-
},
-
"node_modules/@floating-ui/core": {
-
"version": "1.6.9",
-
"license": "MIT",
-
"dependencies": {
-
"@floating-ui/utils": "^0.2.9"
-
}
-
},
-
"node_modules/@floating-ui/dom": {
-
"version": "1.6.13",
-
"license": "MIT",
-
"dependencies": {
-
"@floating-ui/core": "^1.6.0",
-
"@floating-ui/utils": "^0.2.9"
-
}
-
},
-
"node_modules/@floating-ui/react": {
-
"version": "0.26.28",
-
"license": "MIT",
-
"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"
-
}
-
},
-
"node_modules/@floating-ui/react-dom": {
-
"version": "2.1.2",
-
"license": "MIT",
-
"dependencies": {
-
"@floating-ui/dom": "^1.0.0"
-
},
-
"peerDependencies": {
-
"react": ">=16.8.0",
-
"react-dom": ">=16.8.0"
-
}
-
},
-
"node_modules/@floating-ui/utils": {
-
"version": "0.2.9",
-
"license": "MIT"
-
},
-
"node_modules/@hookform/resolvers": {
-
"version": "3.10.0",
-
"license": "MIT",
-
"peerDependencies": {
-
"react-hook-form": "^7.0.0"
-
}
-
},
-
"node_modules/@humanfs/core": {
-
"version": "0.19.1",
-
"dev": true,
-
"license": "Apache-2.0",
-
"engines": {
-
"node": ">=18.18.0"
-
}
-
},
-
"node_modules/@humanfs/node": {
-
"version": "0.16.6",
-
"dev": true,
-
"license": "Apache-2.0",
-
"dependencies": {
-
"@humanfs/core": "^0.19.1",
-
"@humanwhocodes/retry": "^0.3.0"
-
},
-
"engines": {
-
"node": ">=18.18.0"
-
}
-
},
-
"node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
-
"version": "0.3.1",
-
"dev": true,
-
"license": "Apache-2.0",
-
"engines": {
-
"node": ">=18.18"
-
},
-
"funding": {
-
"type": "github",
-
"url": "https://github.com/sponsors/nzakas"
-
}
-
},
-
"node_modules/@humanwhocodes/module-importer": {
-
"version": "1.0.1",
-
"dev": true,
-
"license": "Apache-2.0",
-
"engines": {
-
"node": ">=12.22"
-
},
-
"funding": {
-
"type": "github",
-
"url": "https://github.com/sponsors/nzakas"
-
}
-
},
-
"node_modules/@humanwhocodes/retry": {
-
"version": "0.4.1",
-
"dev": true,
-
"license": "Apache-2.0",
-
"engines": {
-
"node": ">=18.18"
-
},
-
"funding": {
-
"type": "github",
-
"url": "https://github.com/sponsors/nzakas"
-
}
-
},
-
"node_modules/@icons-pack/react-simple-icons": {
-
"version": "11.2.0",
-
"license": "MIT",
-
"peerDependencies": {
-
"react": "^16.13 || ^17 || ^18 || ^19"
-
}
-
},
-
"node_modules/@jridgewell/gen-mapping": {
-
"version": "0.3.8",
-
"license": "MIT",
-
"dependencies": {
-
"@jridgewell/set-array": "^1.2.1",
-
"@jridgewell/sourcemap-codec": "^1.4.10",
-
"@jridgewell/trace-mapping": "^0.3.24"
-
},
-
"engines": {
-
"node": ">=6.0.0"
-
}
-
},
-
"node_modules/@jridgewell/resolve-uri": {
-
"version": "3.1.2",
-
"license": "MIT",
-
"engines": {
-
"node": ">=6.0.0"
-
}
-
},
-
"node_modules/@jridgewell/set-array": {
-
"version": "1.2.1",
-
"license": "MIT",
-
"engines": {
-
"node": ">=6.0.0"
-
}
-
},
-
"node_modules/@jridgewell/sourcemap-codec": {
-
"version": "1.5.0",
-
"license": "MIT"
-
},
-
"node_modules/@jridgewell/trace-mapping": {
-
"version": "0.3.25",
-
"license": "MIT",
-
"dependencies": {
-
"@jridgewell/resolve-uri": "^3.1.0",
-
"@jridgewell/sourcemap-codec": "^1.4.14"
-
}
-
},
-
"node_modules/@mantine/core": {
-
"version": "7.16.1",
-
"license": "MIT",
-
"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"
-
}
-
},
-
"node_modules/@mantine/form": {
-
"version": "7.16.1",
-
"license": "MIT",
-
"dependencies": {
-
"fast-deep-equal": "^3.1.3",
-
"klona": "^2.0.6"
-
},
-
"peerDependencies": {
-
"react": "^18.x || ^19.x"
-
}
-
},
-
"node_modules/@mantine/hooks": {
-
"version": "7.16.1",
-
"license": "MIT",
-
"peerDependencies": {
-
"react": "^18.x || ^19.x"
-
}
-
},
-
"node_modules/@nodelib/fs.scandir": {
-
"version": "2.1.5",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@nodelib/fs.stat": "2.0.5",
-
"run-parallel": "^1.1.9"
-
},
-
"engines": {
-
"node": ">= 8"
-
}
-
},
-
"node_modules/@nodelib/fs.stat": {
-
"version": "2.0.5",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">= 8"
-
}
-
},
-
"node_modules/@nodelib/fs.walk": {
-
"version": "1.2.8",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@nodelib/fs.scandir": "2.1.5",
-
"fastq": "^1.6.0"
-
},
-
"engines": {
-
"node": ">= 8"
-
}
-
},
-
"node_modules/@radix-ui/primitive": {
-
"version": "1.1.1",
-
"license": "MIT"
-
},
-
"node_modules/@radix-ui/react-arrow": {
-
"version": "1.1.1",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-primitive": "2.0.1"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-collection": {
-
"version": "1.1.1",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-compose-refs": "1.1.1",
-
"@radix-ui/react-context": "1.1.1",
-
"@radix-ui/react-primitive": "2.0.1",
-
"@radix-ui/react-slot": "1.1.1"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-compose-refs": {
-
"version": "1.1.1",
-
"license": "MIT",
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-context": {
-
"version": "1.1.1",
-
"license": "MIT",
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-dialog": {
-
"version": "1.1.5",
-
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.5.tgz",
-
"integrity": "sha512-LaO3e5h/NOEL4OfXjxD43k9Dx+vn+8n+PCFt6uhX/BADFflllyv3WJG6rgvvSVBxpTch938Qq/LGc2MMxipXPw==",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/primitive": "1.1.1",
-
"@radix-ui/react-compose-refs": "1.1.1",
-
"@radix-ui/react-context": "1.1.1",
-
"@radix-ui/react-dismissable-layer": "1.1.4",
-
"@radix-ui/react-focus-guards": "1.1.1",
-
"@radix-ui/react-focus-scope": "1.1.1",
-
"@radix-ui/react-id": "1.1.0",
-
"@radix-ui/react-portal": "1.1.3",
-
"@radix-ui/react-presence": "1.1.2",
-
"@radix-ui/react-primitive": "2.0.1",
-
"@radix-ui/react-slot": "1.1.1",
-
"@radix-ui/react-use-controllable-state": "1.1.0",
-
"aria-hidden": "^1.2.4",
-
"react-remove-scroll": "^2.6.2"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-direction": {
-
"version": "1.1.0",
-
"license": "MIT",
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-dismissable-layer": {
-
"version": "1.1.4",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/primitive": "1.1.1",
-
"@radix-ui/react-compose-refs": "1.1.1",
-
"@radix-ui/react-primitive": "2.0.1",
-
"@radix-ui/react-use-callback-ref": "1.1.0",
-
"@radix-ui/react-use-escape-keydown": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-dropdown-menu": {
-
"version": "2.1.5",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/primitive": "1.1.1",
-
"@radix-ui/react-compose-refs": "1.1.1",
-
"@radix-ui/react-context": "1.1.1",
-
"@radix-ui/react-id": "1.1.0",
-
"@radix-ui/react-menu": "2.1.5",
-
"@radix-ui/react-primitive": "2.0.1",
-
"@radix-ui/react-use-controllable-state": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-focus-guards": {
-
"version": "1.1.1",
-
"license": "MIT",
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-focus-scope": {
-
"version": "1.1.1",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-compose-refs": "1.1.1",
-
"@radix-ui/react-primitive": "2.0.1",
-
"@radix-ui/react-use-callback-ref": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-id": {
-
"version": "1.1.0",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-use-layout-effect": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-label": {
-
"version": "2.1.1",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-primitive": "2.0.1"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-menu": {
-
"version": "2.1.5",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/primitive": "1.1.1",
-
"@radix-ui/react-collection": "1.1.1",
-
"@radix-ui/react-compose-refs": "1.1.1",
-
"@radix-ui/react-context": "1.1.1",
-
"@radix-ui/react-direction": "1.1.0",
-
"@radix-ui/react-dismissable-layer": "1.1.4",
-
"@radix-ui/react-focus-guards": "1.1.1",
-
"@radix-ui/react-focus-scope": "1.1.1",
-
"@radix-ui/react-id": "1.1.0",
-
"@radix-ui/react-popper": "1.2.1",
-
"@radix-ui/react-portal": "1.1.3",
-
"@radix-ui/react-presence": "1.1.2",
-
"@radix-ui/react-primitive": "2.0.1",
-
"@radix-ui/react-roving-focus": "1.1.1",
-
"@radix-ui/react-slot": "1.1.1",
-
"@radix-ui/react-use-callback-ref": "1.1.0",
-
"aria-hidden": "^1.2.4",
-
"react-remove-scroll": "^2.6.2"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-popper": {
-
"version": "1.2.1",
-
"license": "MIT",
-
"dependencies": {
-
"@floating-ui/react-dom": "^2.0.0",
-
"@radix-ui/react-arrow": "1.1.1",
-
"@radix-ui/react-compose-refs": "1.1.1",
-
"@radix-ui/react-context": "1.1.1",
-
"@radix-ui/react-primitive": "2.0.1",
-
"@radix-ui/react-use-callback-ref": "1.1.0",
-
"@radix-ui/react-use-layout-effect": "1.1.0",
-
"@radix-ui/react-use-rect": "1.1.0",
-
"@radix-ui/react-use-size": "1.1.0",
-
"@radix-ui/rect": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-portal": {
-
"version": "1.1.3",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-primitive": "2.0.1",
-
"@radix-ui/react-use-layout-effect": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-presence": {
-
"version": "1.1.2",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-compose-refs": "1.1.1",
-
"@radix-ui/react-use-layout-effect": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-primitive": {
-
"version": "2.0.1",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-slot": "1.1.1"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-roving-focus": {
-
"version": "1.1.1",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/primitive": "1.1.1",
-
"@radix-ui/react-collection": "1.1.1",
-
"@radix-ui/react-compose-refs": "1.1.1",
-
"@radix-ui/react-context": "1.1.1",
-
"@radix-ui/react-direction": "1.1.0",
-
"@radix-ui/react-id": "1.1.0",
-
"@radix-ui/react-primitive": "2.0.1",
-
"@radix-ui/react-use-callback-ref": "1.1.0",
-
"@radix-ui/react-use-controllable-state": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-slot": {
-
"version": "1.1.1",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-compose-refs": "1.1.1"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-tabs": {
-
"version": "1.1.2",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/primitive": "1.1.1",
-
"@radix-ui/react-context": "1.1.1",
-
"@radix-ui/react-direction": "1.1.0",
-
"@radix-ui/react-id": "1.1.0",
-
"@radix-ui/react-presence": "1.1.2",
-
"@radix-ui/react-primitive": "2.0.1",
-
"@radix-ui/react-roving-focus": "1.1.1",
-
"@radix-ui/react-use-controllable-state": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-toast": {
-
"version": "1.2.5",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/primitive": "1.1.1",
-
"@radix-ui/react-collection": "1.1.1",
-
"@radix-ui/react-compose-refs": "1.1.1",
-
"@radix-ui/react-context": "1.1.1",
-
"@radix-ui/react-dismissable-layer": "1.1.4",
-
"@radix-ui/react-portal": "1.1.3",
-
"@radix-ui/react-presence": "1.1.2",
-
"@radix-ui/react-primitive": "2.0.1",
-
"@radix-ui/react-use-callback-ref": "1.1.0",
-
"@radix-ui/react-use-controllable-state": "1.1.0",
-
"@radix-ui/react-use-layout-effect": "1.1.0",
-
"@radix-ui/react-visually-hidden": "1.1.1"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-use-callback-ref": {
-
"version": "1.1.0",
-
"license": "MIT",
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-use-controllable-state": {
-
"version": "1.1.0",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-use-callback-ref": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-use-escape-keydown": {
-
"version": "1.1.0",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-use-callback-ref": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-use-layout-effect": {
-
"version": "1.1.0",
-
"license": "MIT",
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-use-rect": {
-
"version": "1.1.0",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/rect": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-use-size": {
-
"version": "1.1.0",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-use-layout-effect": "1.1.0"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/react-visually-hidden": {
-
"version": "1.1.1",
-
"license": "MIT",
-
"dependencies": {
-
"@radix-ui/react-primitive": "2.0.1"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"@types/react-dom": "*",
-
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
-
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
},
-
"@types/react-dom": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/@radix-ui/rect": {
-
"version": "1.1.0",
-
"license": "MIT"
-
},
-
"node_modules/@rollup/rollup-darwin-arm64": {
-
"version": "4.32.0",
-
"cpu": [
-
"arm64"
-
],
-
"license": "MIT",
-
"optional": true,
-
"os": [
-
"darwin"
-
]
-
},
-
"node_modules/@tailwindcss/node": {
-
"version": "4.0.0",
-
"license": "MIT",
-
"dependencies": {
-
"enhanced-resolve": "^5.18.0",
-
"jiti": "^2.4.2",
-
"tailwindcss": "4.0.0"
-
}
-
},
-
"node_modules/@tailwindcss/oxide": {
-
"version": "4.0.0",
-
"license": "MIT",
-
"engines": {
-
"node": ">= 10"
-
},
-
"optionalDependencies": {
-
"@tailwindcss/oxide-android-arm64": "4.0.0",
-
"@tailwindcss/oxide-darwin-arm64": "4.0.0",
-
"@tailwindcss/oxide-darwin-x64": "4.0.0",
-
"@tailwindcss/oxide-freebsd-x64": "4.0.0",
-
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.0",
-
"@tailwindcss/oxide-linux-arm64-gnu": "4.0.0",
-
"@tailwindcss/oxide-linux-arm64-musl": "4.0.0",
-
"@tailwindcss/oxide-linux-x64-gnu": "4.0.0",
-
"@tailwindcss/oxide-linux-x64-musl": "4.0.0",
-
"@tailwindcss/oxide-win32-arm64-msvc": "4.0.0",
-
"@tailwindcss/oxide-win32-x64-msvc": "4.0.0"
-
}
-
},
-
"node_modules/@tailwindcss/oxide-darwin-arm64": {
-
"version": "4.0.0",
-
"cpu": [
-
"arm64"
-
],
-
"license": "MIT",
-
"optional": true,
-
"os": [
-
"darwin"
-
],
-
"engines": {
-
"node": ">= 10"
-
}
-
},
-
"node_modules/@tailwindcss/postcss": {
-
"version": "4.0.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@alloc/quick-lru": "^5.2.0",
-
"@tailwindcss/node": "^4.0.0",
-
"@tailwindcss/oxide": "^4.0.0",
-
"lightningcss": "^1.29.1",
-
"postcss": "^8.4.41",
-
"tailwindcss": "4.0.0"
-
}
-
},
-
"node_modules/@tailwindcss/vite": {
-
"version": "4.0.0",
-
"license": "MIT",
-
"dependencies": {
-
"@tailwindcss/node": "^4.0.0",
-
"@tailwindcss/oxide": "^4.0.0",
-
"lightningcss": "^1.29.1",
-
"tailwindcss": "4.0.0"
-
},
-
"peerDependencies": {
-
"vite": "^5.2.0 || ^6"
-
}
-
},
-
"node_modules/@types/babel__core": {
-
"version": "7.20.5",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@babel/parser": "^7.20.7",
-
"@babel/types": "^7.20.7",
-
"@types/babel__generator": "*",
-
"@types/babel__template": "*",
-
"@types/babel__traverse": "*"
-
}
-
},
-
"node_modules/@types/babel__generator": {
-
"version": "7.6.8",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@babel/types": "^7.0.0"
-
}
-
},
-
"node_modules/@types/babel__template": {
-
"version": "7.4.4",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@babel/parser": "^7.1.0",
-
"@babel/types": "^7.0.0"
-
}
-
},
-
"node_modules/@types/babel__traverse": {
-
"version": "7.20.6",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@babel/types": "^7.20.7"
-
}
-
},
-
"node_modules/@types/d3-array": {
-
"version": "3.2.1",
-
"license": "MIT"
-
},
-
"node_modules/@types/d3-color": {
-
"version": "3.1.3",
-
"license": "MIT"
-
},
-
"node_modules/@types/d3-ease": {
-
"version": "3.0.2",
-
"license": "MIT"
-
},
-
"node_modules/@types/d3-interpolate": {
-
"version": "3.0.4",
-
"license": "MIT",
-
"dependencies": {
-
"@types/d3-color": "*"
-
}
-
},
-
"node_modules/@types/d3-path": {
-
"version": "3.1.0",
-
"license": "MIT"
-
},
-
"node_modules/@types/d3-scale": {
-
"version": "4.0.8",
-
"license": "MIT",
-
"dependencies": {
-
"@types/d3-time": "*"
-
}
-
},
-
"node_modules/@types/d3-shape": {
-
"version": "3.1.7",
-
"license": "MIT",
-
"dependencies": {
-
"@types/d3-path": "*"
-
}
-
},
-
"node_modules/@types/d3-time": {
-
"version": "3.0.4",
-
"license": "MIT"
-
},
-
"node_modules/@types/d3-timer": {
-
"version": "3.0.2",
-
"license": "MIT"
-
},
-
"node_modules/@types/estree": {
-
"version": "1.0.6",
-
"license": "MIT"
-
},
-
"node_modules/@types/json-schema": {
-
"version": "7.0.15",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/@types/node": {
-
"version": "22.10.10",
-
"devOptional": true,
-
"license": "MIT",
-
"dependencies": {
-
"undici-types": "~6.20.0"
-
}
-
},
-
"node_modules/@types/parse-json": {
-
"version": "4.0.2",
-
"license": "MIT"
-
},
-
"node_modules/@types/prop-types": {
-
"version": "15.7.14",
-
"devOptional": true,
-
"license": "MIT"
-
},
-
"node_modules/@types/react": {
-
"version": "18.3.18",
-
"devOptional": true,
-
"license": "MIT",
-
"dependencies": {
-
"@types/prop-types": "*",
-
"csstype": "^3.0.2"
-
}
-
},
-
"node_modules/@types/react-dom": {
-
"version": "18.3.5",
-
"devOptional": true,
-
"license": "MIT",
-
"peerDependencies": {
-
"@types/react": "^18.0.0"
-
}
-
},
-
"node_modules/@typescript-eslint/eslint-plugin": {
-
"version": "8.21.0",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/typescript-eslint"
-
},
-
"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"
-
}
-
},
-
"node_modules/@typescript-eslint/parser": {
-
"version": "8.21.0",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/typescript-eslint"
-
},
-
"peerDependencies": {
-
"eslint": "^8.57.0 || ^9.0.0",
-
"typescript": ">=4.8.4 <5.8.0"
-
}
-
},
-
"node_modules/@typescript-eslint/scope-manager": {
-
"version": "8.21.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@typescript-eslint/types": "8.21.0",
-
"@typescript-eslint/visitor-keys": "8.21.0"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/typescript-eslint"
-
}
-
},
-
"node_modules/@typescript-eslint/type-utils": {
-
"version": "8.21.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@typescript-eslint/typescript-estree": "8.21.0",
-
"@typescript-eslint/utils": "8.21.0",
-
"debug": "^4.3.4",
-
"ts-api-utils": "^2.0.0"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/typescript-eslint"
-
},
-
"peerDependencies": {
-
"eslint": "^8.57.0 || ^9.0.0",
-
"typescript": ">=4.8.4 <5.8.0"
-
}
-
},
-
"node_modules/@typescript-eslint/types": {
-
"version": "8.21.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/typescript-eslint"
-
}
-
},
-
"node_modules/@typescript-eslint/typescript-estree": {
-
"version": "8.21.0",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/typescript-eslint"
-
},
-
"peerDependencies": {
-
"typescript": ">=4.8.4 <5.8.0"
-
}
-
},
-
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
-
"version": "9.0.5",
-
"dev": true,
-
"license": "ISC",
-
"dependencies": {
-
"brace-expansion": "^2.0.1"
-
},
-
"engines": {
-
"node": ">=16 || 14 >=14.17"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/isaacs"
-
}
-
},
-
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion": {
-
"version": "2.0.1",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"balanced-match": "^1.0.0"
-
}
-
},
-
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
-
"version": "7.6.3",
-
"dev": true,
-
"license": "ISC",
-
"bin": {
-
"semver": "bin/semver.js"
-
},
-
"engines": {
-
"node": ">=10"
-
}
-
},
-
"node_modules/@typescript-eslint/utils": {
-
"version": "8.21.0",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/typescript-eslint"
-
},
-
"peerDependencies": {
-
"eslint": "^8.57.0 || ^9.0.0",
-
"typescript": ">=4.8.4 <5.8.0"
-
}
-
},
-
"node_modules/@typescript-eslint/visitor-keys": {
-
"version": "8.21.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@typescript-eslint/types": "8.21.0",
-
"eslint-visitor-keys": "^4.2.0"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/typescript-eslint"
-
}
-
},
-
"node_modules/@vitejs/plugin-react": {
-
"version": "4.3.4",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": "^14.18.0 || >=16.0.0"
-
},
-
"peerDependencies": {
-
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
-
}
-
},
-
"node_modules/acorn": {
-
"version": "8.14.0",
-
"dev": true,
-
"license": "MIT",
-
"bin": {
-
"acorn": "bin/acorn"
-
},
-
"engines": {
-
"node": ">=0.4.0"
-
}
-
},
-
"node_modules/acorn-jsx": {
-
"version": "5.3.2",
-
"dev": true,
-
"license": "MIT",
-
"peerDependencies": {
-
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
-
}
-
},
-
"node_modules/ajv": {
-
"version": "6.12.6",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"funding": {
-
"type": "github",
-
"url": "https://github.com/sponsors/epoberezkin"
-
}
-
},
-
"node_modules/ansi-styles": {
-
"version": "4.3.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"color-convert": "^2.0.1"
-
},
-
"engines": {
-
"node": ">=8"
-
},
-
"funding": {
-
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
-
}
-
},
-
"node_modules/argparse": {
-
"version": "2.0.1",
-
"dev": true,
-
"license": "Python-2.0"
-
},
-
"node_modules/aria-hidden": {
-
"version": "1.2.4",
-
"license": "MIT",
-
"dependencies": {
-
"tslib": "^2.0.0"
-
},
-
"engines": {
-
"node": ">=10"
-
}
-
},
-
"node_modules/asynckit": {
-
"version": "0.4.0",
-
"license": "MIT"
-
},
-
"node_modules/axios": {
-
"version": "1.7.9",
-
"license": "MIT",
-
"dependencies": {
-
"follow-redirects": "^1.15.6",
-
"form-data": "^4.0.0",
-
"proxy-from-env": "^1.1.0"
-
}
-
},
-
"node_modules/babel-plugin-macros": {
-
"version": "3.1.0",
-
"license": "MIT",
-
"dependencies": {
-
"@babel/runtime": "^7.12.5",
-
"cosmiconfig": "^7.0.0",
-
"resolve": "^1.19.0"
-
},
-
"engines": {
-
"node": ">=10",
-
"npm": ">=6"
-
}
-
},
-
"node_modules/balanced-match": {
-
"version": "1.0.2",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/brace-expansion": {
-
"version": "1.1.11",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"balanced-match": "^1.0.0",
-
"concat-map": "0.0.1"
-
}
-
},
-
"node_modules/braces": {
-
"version": "3.0.3",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"fill-range": "^7.1.1"
-
},
-
"engines": {
-
"node": ">=8"
-
}
-
},
-
"node_modules/browserslist": {
-
"version": "4.24.4",
-
"dev": true,
-
"funding": [
-
{
-
"type": "opencollective",
-
"url": "https://opencollective.com/browserslist"
-
},
-
{
-
"type": "tidelift",
-
"url": "https://tidelift.com/funding/github/npm/browserslist"
-
},
-
{
-
"type": "github",
-
"url": "https://github.com/sponsors/ai"
-
}
-
],
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
-
}
-
},
-
"node_modules/callsites": {
-
"version": "3.1.0",
-
"license": "MIT",
-
"engines": {
-
"node": ">=6"
-
}
-
},
-
"node_modules/caniuse-lite": {
-
"version": "1.0.30001695",
-
"dev": true,
-
"funding": [
-
{
-
"type": "opencollective",
-
"url": "https://opencollective.com/browserslist"
-
},
-
{
-
"type": "tidelift",
-
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
-
},
-
{
-
"type": "github",
-
"url": "https://github.com/sponsors/ai"
-
}
-
],
-
"license": "CC-BY-4.0"
-
},
-
"node_modules/chalk": {
-
"version": "4.1.2",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"ansi-styles": "^4.1.0",
-
"supports-color": "^7.1.0"
-
},
-
"engines": {
-
"node": ">=10"
-
},
-
"funding": {
-
"url": "https://github.com/chalk/chalk?sponsor=1"
-
}
-
},
-
"node_modules/class-variance-authority": {
-
"version": "0.7.1",
-
"license": "Apache-2.0",
-
"dependencies": {
-
"clsx": "^2.1.1"
-
},
-
"funding": {
-
"url": "https://polar.sh/cva"
-
}
-
},
-
"node_modules/clsx": {
-
"version": "2.1.1",
-
"license": "MIT",
-
"engines": {
-
"node": ">=6"
-
}
-
},
-
"node_modules/color-convert": {
-
"version": "2.0.1",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"color-name": "~1.1.4"
-
},
-
"engines": {
-
"node": ">=7.0.0"
-
}
-
},
-
"node_modules/color-name": {
-
"version": "1.1.4",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/combined-stream": {
-
"version": "1.0.8",
-
"license": "MIT",
-
"dependencies": {
-
"delayed-stream": "~1.0.0"
-
},
-
"engines": {
-
"node": ">= 0.8"
-
}
-
},
-
"node_modules/concat-map": {
-
"version": "0.0.1",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/convert-source-map": {
-
"version": "2.0.0",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/cosmiconfig": {
-
"version": "7.1.0",
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": ">=10"
-
}
-
},
-
"node_modules/cosmiconfig/node_modules/yaml": {
-
"version": "1.10.2",
-
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
-
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
-
"license": "ISC",
-
"engines": {
-
"node": ">= 6"
-
}
-
},
-
"node_modules/cross-spawn": {
-
"version": "7.0.6",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"path-key": "^3.1.0",
-
"shebang-command": "^2.0.0",
-
"which": "^2.0.1"
-
},
-
"engines": {
-
"node": ">= 8"
-
}
-
},
-
"node_modules/csstype": {
-
"version": "3.1.3",
-
"license": "MIT"
-
},
-
"node_modules/d3-array": {
-
"version": "3.2.4",
-
"license": "ISC",
-
"dependencies": {
-
"internmap": "1 - 2"
-
},
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/d3-color": {
-
"version": "3.1.0",
-
"license": "ISC",
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/d3-ease": {
-
"version": "3.0.1",
-
"license": "BSD-3-Clause",
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/d3-format": {
-
"version": "3.1.0",
-
"license": "ISC",
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/d3-interpolate": {
-
"version": "3.0.1",
-
"license": "ISC",
-
"dependencies": {
-
"d3-color": "1 - 3"
-
},
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/d3-path": {
-
"version": "3.1.0",
-
"license": "ISC",
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/d3-scale": {
-
"version": "4.0.2",
-
"license": "ISC",
-
"dependencies": {
-
"d3-array": "2.10.0 - 3",
-
"d3-format": "1 - 3",
-
"d3-interpolate": "1.2.0 - 3",
-
"d3-time": "2.1.1 - 3",
-
"d3-time-format": "2 - 4"
-
},
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/d3-shape": {
-
"version": "3.2.0",
-
"license": "ISC",
-
"dependencies": {
-
"d3-path": "^3.1.0"
-
},
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/d3-time": {
-
"version": "3.1.0",
-
"license": "ISC",
-
"dependencies": {
-
"d3-array": "2 - 3"
-
},
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/d3-time-format": {
-
"version": "4.1.0",
-
"license": "ISC",
-
"dependencies": {
-
"d3-time": "1 - 3"
-
},
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/d3-timer": {
-
"version": "3.0.1",
-
"license": "ISC",
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/debug": {
-
"version": "4.4.0",
-
"license": "MIT",
-
"dependencies": {
-
"ms": "^2.1.3"
-
},
-
"engines": {
-
"node": ">=6.0"
-
},
-
"peerDependenciesMeta": {
-
"supports-color": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/decimal.js-light": {
-
"version": "2.5.1",
-
"license": "MIT"
-
},
-
"node_modules/deep-is": {
-
"version": "0.1.4",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/delayed-stream": {
-
"version": "1.0.0",
-
"license": "MIT",
-
"engines": {
-
"node": ">=0.4.0"
-
}
-
},
-
"node_modules/detect-libc": {
-
"version": "1.0.3",
-
"license": "Apache-2.0",
-
"bin": {
-
"detect-libc": "bin/detect-libc.js"
-
},
-
"engines": {
-
"node": ">=0.10"
-
}
-
},
-
"node_modules/detect-node-es": {
-
"version": "1.1.0",
-
"license": "MIT"
-
},
-
"node_modules/dom-helpers": {
-
"version": "5.2.1",
-
"license": "MIT",
-
"dependencies": {
-
"@babel/runtime": "^7.8.7",
-
"csstype": "^3.0.2"
-
}
-
},
-
"node_modules/electron-to-chromium": {
-
"version": "1.5.88",
-
"dev": true,
-
"license": "ISC"
-
},
-
"node_modules/enhanced-resolve": {
-
"version": "5.18.0",
-
"license": "MIT",
-
"dependencies": {
-
"graceful-fs": "^4.2.4",
-
"tapable": "^2.2.0"
-
},
-
"engines": {
-
"node": ">=10.13.0"
-
}
-
},
-
"node_modules/error-ex": {
-
"version": "1.3.2",
-
"license": "MIT",
-
"dependencies": {
-
"is-arrayish": "^0.2.1"
-
}
-
},
-
"node_modules/esbuild": {
-
"version": "0.24.2",
-
"hasInstallScript": true,
-
"license": "MIT",
-
"bin": {
-
"esbuild": "bin/esbuild"
-
},
-
"engines": {
-
"node": ">=18"
-
},
-
"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"
-
}
-
},
-
"node_modules/escalade": {
-
"version": "3.2.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=6"
-
}
-
},
-
"node_modules/escape-string-regexp": {
-
"version": "4.0.0",
-
"license": "MIT",
-
"engines": {
-
"node": ">=10"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/eslint": {
-
"version": "9.19.0",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"bin": {
-
"eslint": "bin/eslint.js"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"url": "https://eslint.org/donate"
-
},
-
"peerDependencies": {
-
"jiti": "*"
-
},
-
"peerDependenciesMeta": {
-
"jiti": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/eslint-plugin-react-hooks": {
-
"version": "5.1.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=10"
-
},
-
"peerDependencies": {
-
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
-
}
-
},
-
"node_modules/eslint-plugin-react-refresh": {
-
"version": "0.4.18",
-
"dev": true,
-
"license": "MIT",
-
"peerDependencies": {
-
"eslint": ">=8.40"
-
}
-
},
-
"node_modules/eslint-scope": {
-
"version": "8.2.0",
-
"dev": true,
-
"license": "BSD-2-Clause",
-
"dependencies": {
-
"esrecurse": "^4.3.0",
-
"estraverse": "^5.2.0"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"url": "https://opencollective.com/eslint"
-
}
-
},
-
"node_modules/eslint-visitor-keys": {
-
"version": "4.2.0",
-
"dev": true,
-
"license": "Apache-2.0",
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"url": "https://opencollective.com/eslint"
-
}
-
},
-
"node_modules/espree": {
-
"version": "10.3.0",
-
"dev": true,
-
"license": "BSD-2-Clause",
-
"dependencies": {
-
"acorn": "^8.14.0",
-
"acorn-jsx": "^5.3.2",
-
"eslint-visitor-keys": "^4.2.0"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"url": "https://opencollective.com/eslint"
-
}
-
},
-
"node_modules/esquery": {
-
"version": "1.6.0",
-
"dev": true,
-
"license": "BSD-3-Clause",
-
"dependencies": {
-
"estraverse": "^5.1.0"
-
},
-
"engines": {
-
"node": ">=0.10"
-
}
-
},
-
"node_modules/esrecurse": {
-
"version": "4.3.0",
-
"dev": true,
-
"license": "BSD-2-Clause",
-
"dependencies": {
-
"estraverse": "^5.2.0"
-
},
-
"engines": {
-
"node": ">=4.0"
-
}
-
},
-
"node_modules/estraverse": {
-
"version": "5.3.0",
-
"dev": true,
-
"license": "BSD-2-Clause",
-
"engines": {
-
"node": ">=4.0"
-
}
-
},
-
"node_modules/esutils": {
-
"version": "2.0.3",
-
"dev": true,
-
"license": "BSD-2-Clause",
-
"engines": {
-
"node": ">=0.10.0"
-
}
-
},
-
"node_modules/eventemitter3": {
-
"version": "4.0.7",
-
"license": "MIT"
-
},
-
"node_modules/fast-deep-equal": {
-
"version": "3.1.3",
-
"license": "MIT"
-
},
-
"node_modules/fast-equals": {
-
"version": "5.2.2",
-
"license": "MIT",
-
"engines": {
-
"node": ">=6.0.0"
-
}
-
},
-
"node_modules/fast-glob": {
-
"version": "3.3.3",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": ">=8.6.0"
-
}
-
},
-
"node_modules/fast-glob/node_modules/glob-parent": {
-
"version": "5.1.2",
-
"dev": true,
-
"license": "ISC",
-
"dependencies": {
-
"is-glob": "^4.0.1"
-
},
-
"engines": {
-
"node": ">= 6"
-
}
-
},
-
"node_modules/fast-json-stable-stringify": {
-
"version": "2.1.0",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/fast-levenshtein": {
-
"version": "2.0.6",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/fastq": {
-
"version": "1.18.0",
-
"dev": true,
-
"license": "ISC",
-
"dependencies": {
-
"reusify": "^1.0.4"
-
}
-
},
-
"node_modules/file-entry-cache": {
-
"version": "8.0.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"flat-cache": "^4.0.0"
-
},
-
"engines": {
-
"node": ">=16.0.0"
-
}
-
},
-
"node_modules/fill-range": {
-
"version": "7.1.1",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"to-regex-range": "^5.0.1"
-
},
-
"engines": {
-
"node": ">=8"
-
}
-
},
-
"node_modules/find-root": {
-
"version": "1.1.0",
-
"license": "MIT"
-
},
-
"node_modules/find-up": {
-
"version": "5.0.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"locate-path": "^6.0.0",
-
"path-exists": "^4.0.0"
-
},
-
"engines": {
-
"node": ">=10"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/flat-cache": {
-
"version": "4.0.1",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"flatted": "^3.2.9",
-
"keyv": "^4.5.4"
-
},
-
"engines": {
-
"node": ">=16"
-
}
-
},
-
"node_modules/flatted": {
-
"version": "3.3.2",
-
"dev": true,
-
"license": "ISC"
-
},
-
"node_modules/follow-redirects": {
-
"version": "1.15.9",
-
"funding": [
-
{
-
"type": "individual",
-
"url": "https://github.com/sponsors/RubenVerborgh"
-
}
-
],
-
"license": "MIT",
-
"engines": {
-
"node": ">=4.0"
-
},
-
"peerDependenciesMeta": {
-
"debug": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/form-data": {
-
"version": "4.0.1",
-
"license": "MIT",
-
"dependencies": {
-
"asynckit": "^0.4.0",
-
"combined-stream": "^1.0.8",
-
"mime-types": "^2.1.12"
-
},
-
"engines": {
-
"node": ">= 6"
-
}
-
},
-
"node_modules/fsevents": {
-
"version": "2.3.3",
-
"license": "MIT",
-
"optional": true,
-
"os": [
-
"darwin"
-
],
-
"engines": {
-
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-
}
-
},
-
"node_modules/function-bind": {
-
"version": "1.1.2",
-
"license": "MIT",
-
"funding": {
-
"url": "https://github.com/sponsors/ljharb"
-
}
-
},
-
"node_modules/gensync": {
-
"version": "1.0.0-beta.2",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=6.9.0"
-
}
-
},
-
"node_modules/get-nonce": {
-
"version": "1.0.1",
-
"license": "MIT",
-
"engines": {
-
"node": ">=6"
-
}
-
},
-
"node_modules/glob-parent": {
-
"version": "6.0.2",
-
"dev": true,
-
"license": "ISC",
-
"dependencies": {
-
"is-glob": "^4.0.3"
-
},
-
"engines": {
-
"node": ">=10.13.0"
-
}
-
},
-
"node_modules/globals": {
-
"version": "15.14.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=18"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/graceful-fs": {
-
"version": "4.2.11",
-
"license": "ISC"
-
},
-
"node_modules/graphemer": {
-
"version": "1.4.0",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/has-flag": {
-
"version": "4.0.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=8"
-
}
-
},
-
"node_modules/hasown": {
-
"version": "2.0.2",
-
"license": "MIT",
-
"dependencies": {
-
"function-bind": "^1.1.2"
-
},
-
"engines": {
-
"node": ">= 0.4"
-
}
-
},
-
"node_modules/hoist-non-react-statics": {
-
"version": "3.3.2",
-
"license": "BSD-3-Clause",
-
"dependencies": {
-
"react-is": "^16.7.0"
-
}
-
},
-
"node_modules/hoist-non-react-statics/node_modules/react-is": {
-
"version": "16.13.1",
-
"license": "MIT"
-
},
-
"node_modules/ignore": {
-
"version": "5.3.2",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">= 4"
-
}
-
},
-
"node_modules/import-fresh": {
-
"version": "3.3.0",
-
"license": "MIT",
-
"dependencies": {
-
"parent-module": "^1.0.0",
-
"resolve-from": "^4.0.0"
-
},
-
"engines": {
-
"node": ">=6"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/imurmurhash": {
-
"version": "0.1.4",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=0.8.19"
-
}
-
},
-
"node_modules/internmap": {
-
"version": "2.0.3",
-
"license": "ISC",
-
"engines": {
-
"node": ">=12"
-
}
-
},
-
"node_modules/is-arrayish": {
-
"version": "0.2.1",
-
"license": "MIT"
-
},
-
"node_modules/is-core-module": {
-
"version": "2.16.1",
-
"license": "MIT",
-
"dependencies": {
-
"hasown": "^2.0.2"
-
},
-
"engines": {
-
"node": ">= 0.4"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/ljharb"
-
}
-
},
-
"node_modules/is-extglob": {
-
"version": "2.1.1",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=0.10.0"
-
}
-
},
-
"node_modules/is-glob": {
-
"version": "4.0.3",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"is-extglob": "^2.1.1"
-
},
-
"engines": {
-
"node": ">=0.10.0"
-
}
-
},
-
"node_modules/is-number": {
-
"version": "7.0.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=0.12.0"
-
}
-
},
-
"node_modules/isexe": {
-
"version": "2.0.0",
-
"dev": true,
-
"license": "ISC"
-
},
-
"node_modules/jiti": {
-
"version": "2.4.2",
-
"license": "MIT",
-
"bin": {
-
"jiti": "lib/jiti-cli.mjs"
-
}
-
},
-
"node_modules/js-tokens": {
-
"version": "4.0.0",
-
"license": "MIT"
-
},
-
"node_modules/js-yaml": {
-
"version": "4.1.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"argparse": "^2.0.1"
-
},
-
"bin": {
-
"js-yaml": "bin/js-yaml.js"
-
}
-
},
-
"node_modules/jsesc": {
-
"version": "3.1.0",
-
"license": "MIT",
-
"bin": {
-
"jsesc": "bin/jsesc"
-
},
-
"engines": {
-
"node": ">=6"
-
}
-
},
-
"node_modules/json-buffer": {
-
"version": "3.0.1",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/json-parse-even-better-errors": {
-
"version": "2.3.1",
-
"license": "MIT"
-
},
-
"node_modules/json-schema-traverse": {
-
"version": "0.4.1",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/json-stable-stringify-without-jsonify": {
-
"version": "1.0.1",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/json5": {
-
"version": "2.2.3",
-
"dev": true,
-
"license": "MIT",
-
"bin": {
-
"json5": "lib/cli.js"
-
},
-
"engines": {
-
"node": ">=6"
-
}
-
},
-
"node_modules/keyv": {
-
"version": "4.5.4",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"json-buffer": "3.0.1"
-
}
-
},
-
"node_modules/klona": {
-
"version": "2.0.6",
-
"license": "MIT",
-
"engines": {
-
"node": ">= 8"
-
}
-
},
-
"node_modules/levn": {
-
"version": "0.4.1",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"prelude-ls": "^1.2.1",
-
"type-check": "~0.4.0"
-
},
-
"engines": {
-
"node": ">= 0.8.0"
-
}
-
},
-
"node_modules/lightningcss": {
-
"version": "1.29.1",
-
"license": "MPL-2.0",
-
"dependencies": {
-
"detect-libc": "^1.0.3"
-
},
-
"engines": {
-
"node": ">= 12.0.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/parcel"
-
},
-
"optionalDependencies": {
-
"lightningcss-darwin-arm64": "1.29.1",
-
"lightningcss-darwin-x64": "1.29.1",
-
"lightningcss-freebsd-x64": "1.29.1",
-
"lightningcss-linux-arm-gnueabihf": "1.29.1",
-
"lightningcss-linux-arm64-gnu": "1.29.1",
-
"lightningcss-linux-arm64-musl": "1.29.1",
-
"lightningcss-linux-x64-gnu": "1.29.1",
-
"lightningcss-linux-x64-musl": "1.29.1",
-
"lightningcss-win32-arm64-msvc": "1.29.1",
-
"lightningcss-win32-x64-msvc": "1.29.1"
-
}
-
},
-
"node_modules/lightningcss-darwin-arm64": {
-
"version": "1.29.1",
-
"cpu": [
-
"arm64"
-
],
-
"license": "MPL-2.0",
-
"optional": true,
-
"os": [
-
"darwin"
-
],
-
"engines": {
-
"node": ">= 12.0.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/parcel"
-
}
-
},
-
"node_modules/lines-and-columns": {
-
"version": "1.2.4",
-
"license": "MIT"
-
},
-
"node_modules/locate-path": {
-
"version": "6.0.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"p-locate": "^5.0.0"
-
},
-
"engines": {
-
"node": ">=10"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/lodash": {
-
"version": "4.17.21",
-
"license": "MIT"
-
},
-
"node_modules/lodash.merge": {
-
"version": "4.6.2",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/loose-envify": {
-
"version": "1.4.0",
-
"license": "MIT",
-
"dependencies": {
-
"js-tokens": "^3.0.0 || ^4.0.0"
-
},
-
"bin": {
-
"loose-envify": "cli.js"
-
}
-
},
-
"node_modules/lru-cache": {
-
"version": "5.1.1",
-
"dev": true,
-
"license": "ISC",
-
"dependencies": {
-
"yallist": "^3.0.2"
-
}
-
},
-
"node_modules/lucide-react": {
-
"version": "0.474.0",
-
"license": "ISC",
-
"peerDependencies": {
-
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
-
}
-
},
-
"node_modules/merge2": {
-
"version": "1.4.1",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">= 8"
-
}
-
},
-
"node_modules/micromatch": {
-
"version": "4.0.8",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"braces": "^3.0.3",
-
"picomatch": "^2.3.1"
-
},
-
"engines": {
-
"node": ">=8.6"
-
}
-
},
-
"node_modules/mime-db": {
-
"version": "1.52.0",
-
"license": "MIT",
-
"engines": {
-
"node": ">= 0.6"
-
}
-
},
-
"node_modules/mime-types": {
-
"version": "2.1.35",
-
"license": "MIT",
-
"dependencies": {
-
"mime-db": "1.52.0"
-
},
-
"engines": {
-
"node": ">= 0.6"
-
}
-
},
-
"node_modules/minimatch": {
-
"version": "3.1.2",
-
"dev": true,
-
"license": "ISC",
-
"dependencies": {
-
"brace-expansion": "^1.1.7"
-
},
-
"engines": {
-
"node": "*"
-
}
-
},
-
"node_modules/ms": {
-
"version": "2.1.3",
-
"license": "MIT"
-
},
-
"node_modules/nanoid": {
-
"version": "3.3.8",
-
"funding": [
-
{
-
"type": "github",
-
"url": "https://github.com/sponsors/ai"
-
}
-
],
-
"license": "MIT",
-
"bin": {
-
"nanoid": "bin/nanoid.cjs"
-
},
-
"engines": {
-
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
-
}
-
},
-
"node_modules/natural-compare": {
-
"version": "1.4.0",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/node-releases": {
-
"version": "2.0.19",
-
"dev": true,
-
"license": "MIT"
-
},
-
"node_modules/object-assign": {
-
"version": "4.1.1",
-
"license": "MIT",
-
"engines": {
-
"node": ">=0.10.0"
-
}
-
},
-
"node_modules/optionator": {
-
"version": "0.9.4",
-
"dev": true,
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": ">= 0.8.0"
-
}
-
},
-
"node_modules/p-limit": {
-
"version": "3.1.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"yocto-queue": "^0.1.0"
-
},
-
"engines": {
-
"node": ">=10"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/p-locate": {
-
"version": "5.0.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"p-limit": "^3.0.2"
-
},
-
"engines": {
-
"node": ">=10"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/parent-module": {
-
"version": "1.0.1",
-
"license": "MIT",
-
"dependencies": {
-
"callsites": "^3.0.0"
-
},
-
"engines": {
-
"node": ">=6"
-
}
-
},
-
"node_modules/parse-json": {
-
"version": "5.2.0",
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": ">=8"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/path-exists": {
-
"version": "4.0.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=8"
-
}
-
},
-
"node_modules/path-key": {
-
"version": "3.1.1",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=8"
-
}
-
},
-
"node_modules/path-parse": {
-
"version": "1.0.7",
-
"license": "MIT"
-
},
-
"node_modules/path-type": {
-
"version": "4.0.0",
-
"license": "MIT",
-
"engines": {
-
"node": ">=8"
-
}
-
},
-
"node_modules/picocolors": {
-
"version": "1.1.1",
-
"license": "ISC"
-
},
-
"node_modules/picomatch": {
-
"version": "2.3.1",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=8.6"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/jonschlinkert"
-
}
-
},
-
"node_modules/postcss": {
-
"version": "8.5.1",
-
"funding": [
-
{
-
"type": "opencollective",
-
"url": "https://opencollective.com/postcss/"
-
},
-
{
-
"type": "tidelift",
-
"url": "https://tidelift.com/funding/github/npm/postcss"
-
},
-
{
-
"type": "github",
-
"url": "https://github.com/sponsors/ai"
-
}
-
],
-
"license": "MIT",
-
"dependencies": {
-
"nanoid": "^3.3.8",
-
"picocolors": "^1.1.1",
-
"source-map-js": "^1.2.1"
-
},
-
"engines": {
-
"node": "^10 || ^12 || >=14"
-
}
-
},
-
"node_modules/prelude-ls": {
-
"version": "1.2.1",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">= 0.8.0"
-
}
-
},
-
"node_modules/prop-types": {
-
"version": "15.8.1",
-
"license": "MIT",
-
"dependencies": {
-
"loose-envify": "^1.4.0",
-
"object-assign": "^4.1.1",
-
"react-is": "^16.13.1"
-
}
-
},
-
"node_modules/prop-types/node_modules/react-is": {
-
"version": "16.13.1",
-
"license": "MIT"
-
},
-
"node_modules/proxy-from-env": {
-
"version": "1.1.0",
-
"license": "MIT"
-
},
-
"node_modules/punycode": {
-
"version": "2.3.1",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=6"
-
}
-
},
-
"node_modules/queue-microtask": {
-
"version": "1.2.3",
-
"dev": true,
-
"funding": [
-
{
-
"type": "github",
-
"url": "https://github.com/sponsors/feross"
-
},
-
{
-
"type": "patreon",
-
"url": "https://www.patreon.com/feross"
-
},
-
{
-
"type": "consulting",
-
"url": "https://feross.org/support"
-
}
-
],
-
"license": "MIT"
-
},
-
"node_modules/react": {
-
"version": "18.3.1",
-
"license": "MIT",
-
"dependencies": {
-
"loose-envify": "^1.1.0"
-
},
-
"engines": {
-
"node": ">=0.10.0"
-
}
-
},
-
"node_modules/react-dom": {
-
"version": "18.3.1",
-
"license": "MIT",
-
"dependencies": {
-
"loose-envify": "^1.1.0",
-
"scheduler": "^0.23.2"
-
},
-
"peerDependencies": {
-
"react": "^18.3.1"
-
}
-
},
-
"node_modules/react-hook-form": {
-
"version": "7.54.2",
-
"license": "MIT",
-
"engines": {
-
"node": ">=18.0.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/react-hook-form"
-
},
-
"peerDependencies": {
-
"react": "^16.8.0 || ^17 || ^18 || ^19"
-
}
-
},
-
"node_modules/react-is": {
-
"version": "18.3.1",
-
"license": "MIT"
-
},
-
"node_modules/react-number-format": {
-
"version": "5.4.3",
-
"license": "MIT",
-
"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"
-
}
-
},
-
"node_modules/react-refresh": {
-
"version": "0.14.2",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=0.10.0"
-
}
-
},
-
"node_modules/react-remove-scroll": {
-
"version": "2.6.3",
-
"license": "MIT",
-
"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"
-
},
-
"engines": {
-
"node": ">=10"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/react-remove-scroll-bar": {
-
"version": "2.3.8",
-
"license": "MIT",
-
"dependencies": {
-
"react-style-singleton": "^2.2.2",
-
"tslib": "^2.0.0"
-
},
-
"engines": {
-
"node": ">=10"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/react-smooth": {
-
"version": "4.0.4",
-
"license": "MIT",
-
"dependencies": {
-
"fast-equals": "^5.0.1",
-
"prop-types": "^15.8.1",
-
"react-transition-group": "^4.4.5"
-
},
-
"peerDependencies": {
-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
-
}
-
},
-
"node_modules/react-style-singleton": {
-
"version": "2.2.3",
-
"license": "MIT",
-
"dependencies": {
-
"get-nonce": "^1.0.0",
-
"tslib": "^2.0.0"
-
},
-
"engines": {
-
"node": ">=10"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/react-textarea-autosize": {
-
"version": "8.5.6",
-
"license": "MIT",
-
"dependencies": {
-
"@babel/runtime": "^7.20.13",
-
"use-composed-ref": "^1.3.0",
-
"use-latest": "^1.2.1"
-
},
-
"engines": {
-
"node": ">=10"
-
},
-
"peerDependencies": {
-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
-
}
-
},
-
"node_modules/react-transition-group": {
-
"version": "4.4.5",
-
"license": "BSD-3-Clause",
-
"dependencies": {
-
"@babel/runtime": "^7.5.5",
-
"dom-helpers": "^5.0.1",
-
"loose-envify": "^1.4.0",
-
"prop-types": "^15.6.2"
-
},
-
"peerDependencies": {
-
"react": ">=16.6.0",
-
"react-dom": ">=16.6.0"
-
}
-
},
-
"node_modules/recharts": {
-
"version": "2.15.0",
-
"license": "MIT",
-
"dependencies": {
-
"clsx": "^2.0.0",
-
"eventemitter3": "^4.0.1",
-
"lodash": "^4.17.21",
-
"react-is": "^18.3.1",
-
"react-smooth": "^4.0.0",
-
"recharts-scale": "^0.4.4",
-
"tiny-invariant": "^1.3.1",
-
"victory-vendor": "^36.6.8"
-
},
-
"engines": {
-
"node": ">=14"
-
},
-
"peerDependencies": {
-
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
-
}
-
},
-
"node_modules/recharts-scale": {
-
"version": "0.4.5",
-
"license": "MIT",
-
"dependencies": {
-
"decimal.js-light": "^2.4.1"
-
}
-
},
-
"node_modules/regenerator-runtime": {
-
"version": "0.14.1",
-
"license": "MIT"
-
},
-
"node_modules/resolve": {
-
"version": "1.22.10",
-
"license": "MIT",
-
"dependencies": {
-
"is-core-module": "^2.16.0",
-
"path-parse": "^1.0.7",
-
"supports-preserve-symlinks-flag": "^1.0.0"
-
},
-
"bin": {
-
"resolve": "bin/resolve"
-
},
-
"engines": {
-
"node": ">= 0.4"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/ljharb"
-
}
-
},
-
"node_modules/resolve-from": {
-
"version": "4.0.0",
-
"license": "MIT",
-
"engines": {
-
"node": ">=4"
-
}
-
},
-
"node_modules/reusify": {
-
"version": "1.0.4",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"iojs": ">=1.0.0",
-
"node": ">=0.10.0"
-
}
-
},
-
"node_modules/rollup": {
-
"version": "4.32.0",
-
"license": "MIT",
-
"dependencies": {
-
"@types/estree": "1.0.6"
-
},
-
"bin": {
-
"rollup": "dist/bin/rollup"
-
},
-
"engines": {
-
"node": ">=18.0.0",
-
"npm": ">=8.0.0"
-
},
-
"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"
-
}
-
},
-
"node_modules/run-parallel": {
-
"version": "1.2.0",
-
"dev": true,
-
"funding": [
-
{
-
"type": "github",
-
"url": "https://github.com/sponsors/feross"
-
},
-
{
-
"type": "patreon",
-
"url": "https://www.patreon.com/feross"
-
},
-
{
-
"type": "consulting",
-
"url": "https://feross.org/support"
-
}
-
],
-
"license": "MIT",
-
"dependencies": {
-
"queue-microtask": "^1.2.2"
-
}
-
},
-
"node_modules/scheduler": {
-
"version": "0.23.2",
-
"license": "MIT",
-
"dependencies": {
-
"loose-envify": "^1.1.0"
-
}
-
},
-
"node_modules/semver": {
-
"version": "6.3.1",
-
"dev": true,
-
"license": "ISC",
-
"bin": {
-
"semver": "bin/semver.js"
-
}
-
},
-
"node_modules/shebang-command": {
-
"version": "2.0.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"shebang-regex": "^3.0.0"
-
},
-
"engines": {
-
"node": ">=8"
-
}
-
},
-
"node_modules/shebang-regex": {
-
"version": "3.0.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=8"
-
}
-
},
-
"node_modules/source-map": {
-
"version": "0.5.7",
-
"license": "BSD-3-Clause",
-
"engines": {
-
"node": ">=0.10.0"
-
}
-
},
-
"node_modules/source-map-js": {
-
"version": "1.2.1",
-
"license": "BSD-3-Clause",
-
"engines": {
-
"node": ">=0.10.0"
-
}
-
},
-
"node_modules/strip-json-comments": {
-
"version": "3.1.1",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=8"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/stylis": {
-
"version": "4.2.0",
-
"license": "MIT"
-
},
-
"node_modules/supports-color": {
-
"version": "7.2.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"has-flag": "^4.0.0"
-
},
-
"engines": {
-
"node": ">=8"
-
}
-
},
-
"node_modules/supports-preserve-symlinks-flag": {
-
"version": "1.0.0",
-
"license": "MIT",
-
"engines": {
-
"node": ">= 0.4"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/ljharb"
-
}
-
},
-
"node_modules/tabbable": {
-
"version": "6.2.0",
-
"license": "MIT"
-
},
-
"node_modules/tailwind-merge": {
-
"version": "2.6.0",
-
"license": "MIT",
-
"funding": {
-
"type": "github",
-
"url": "https://github.com/sponsors/dcastil"
-
}
-
},
-
"node_modules/tailwindcss": {
-
"version": "4.0.0",
-
"license": "MIT"
-
},
-
"node_modules/tailwindcss-animate": {
-
"version": "1.0.7",
-
"license": "MIT",
-
"peerDependencies": {
-
"tailwindcss": ">=3.0.0 || insiders"
-
}
-
},
-
"node_modules/tapable": {
-
"version": "2.2.1",
-
"license": "MIT",
-
"engines": {
-
"node": ">=6"
-
}
-
},
-
"node_modules/tiny-invariant": {
-
"version": "1.3.3",
-
"license": "MIT"
-
},
-
"node_modules/to-regex-range": {
-
"version": "5.0.1",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"is-number": "^7.0.0"
-
},
-
"engines": {
-
"node": ">=8.0"
-
}
-
},
-
"node_modules/ts-api-utils": {
-
"version": "2.0.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=18.12"
-
},
-
"peerDependencies": {
-
"typescript": ">=4.8.4"
-
}
-
},
-
"node_modules/tslib": {
-
"version": "2.8.1",
-
"license": "0BSD"
-
},
-
"node_modules/type-check": {
-
"version": "0.4.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"prelude-ls": "^1.2.1"
-
},
-
"engines": {
-
"node": ">= 0.8.0"
-
}
-
},
-
"node_modules/type-fest": {
-
"version": "4.33.0",
-
"license": "(MIT OR CC0-1.0)",
-
"engines": {
-
"node": ">=16"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/typescript": {
-
"version": "5.6.3",
-
"dev": true,
-
"license": "Apache-2.0",
-
"bin": {
-
"tsc": "bin/tsc",
-
"tsserver": "bin/tsserver"
-
},
-
"engines": {
-
"node": ">=14.17"
-
}
-
},
-
"node_modules/typescript-eslint": {
-
"version": "8.21.0",
-
"dev": true,
-
"license": "MIT",
-
"dependencies": {
-
"@typescript-eslint/eslint-plugin": "8.21.0",
-
"@typescript-eslint/parser": "8.21.0",
-
"@typescript-eslint/utils": "8.21.0"
-
},
-
"engines": {
-
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-
},
-
"funding": {
-
"type": "opencollective",
-
"url": "https://opencollective.com/typescript-eslint"
-
},
-
"peerDependencies": {
-
"eslint": "^8.57.0 || ^9.0.0",
-
"typescript": ">=4.8.4 <5.8.0"
-
}
-
},
-
"node_modules/undici-types": {
-
"version": "6.20.0",
-
"devOptional": true,
-
"license": "MIT"
-
},
-
"node_modules/update-browserslist-db": {
-
"version": "1.1.2",
-
"dev": true,
-
"funding": [
-
{
-
"type": "opencollective",
-
"url": "https://opencollective.com/browserslist"
-
},
-
{
-
"type": "tidelift",
-
"url": "https://tidelift.com/funding/github/npm/browserslist"
-
},
-
{
-
"type": "github",
-
"url": "https://github.com/sponsors/ai"
-
}
-
],
-
"license": "MIT",
-
"dependencies": {
-
"escalade": "^3.2.0",
-
"picocolors": "^1.1.1"
-
},
-
"bin": {
-
"update-browserslist-db": "cli.js"
-
},
-
"peerDependencies": {
-
"browserslist": ">= 4.21.0"
-
}
-
},
-
"node_modules/uri-js": {
-
"version": "4.4.1",
-
"dev": true,
-
"license": "BSD-2-Clause",
-
"dependencies": {
-
"punycode": "^2.1.0"
-
}
-
},
-
"node_modules/use-callback-ref": {
-
"version": "1.3.3",
-
"license": "MIT",
-
"dependencies": {
-
"tslib": "^2.0.0"
-
},
-
"engines": {
-
"node": ">=10"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/use-composed-ref": {
-
"version": "1.4.0",
-
"license": "MIT",
-
"peerDependencies": {
-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/use-isomorphic-layout-effect": {
-
"version": "1.2.0",
-
"license": "MIT",
-
"peerDependencies": {
-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/use-latest": {
-
"version": "1.3.0",
-
"license": "MIT",
-
"dependencies": {
-
"use-isomorphic-layout-effect": "^1.1.1"
-
},
-
"peerDependencies": {
-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/use-sidecar": {
-
"version": "1.1.3",
-
"license": "MIT",
-
"dependencies": {
-
"detect-node-es": "^1.1.0",
-
"tslib": "^2.0.0"
-
},
-
"engines": {
-
"node": ">=10"
-
},
-
"peerDependencies": {
-
"@types/react": "*",
-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
-
},
-
"peerDependenciesMeta": {
-
"@types/react": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/victory-vendor": {
-
"version": "36.9.2",
-
"license": "MIT AND ISC",
-
"dependencies": {
-
"@types/d3-array": "^3.0.3",
-
"@types/d3-ease": "^3.0.0",
-
"@types/d3-interpolate": "^3.0.1",
-
"@types/d3-scale": "^4.0.2",
-
"@types/d3-shape": "^3.1.0",
-
"@types/d3-time": "^3.0.0",
-
"@types/d3-timer": "^3.0.0",
-
"d3-array": "^3.1.6",
-
"d3-ease": "^3.0.1",
-
"d3-interpolate": "^3.0.1",
-
"d3-scale": "^4.0.2",
-
"d3-shape": "^3.1.0",
-
"d3-time": "^3.0.0",
-
"d3-timer": "^3.0.1"
-
}
-
},
-
"node_modules/vite": {
-
"version": "6.0.11",
-
"license": "MIT",
-
"dependencies": {
-
"esbuild": "^0.24.2",
-
"postcss": "^8.4.49",
-
"rollup": "^4.23.0"
-
},
-
"bin": {
-
"vite": "bin/vite.js"
-
},
-
"engines": {
-
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
-
},
-
"funding": {
-
"url": "https://github.com/vitejs/vite?sponsor=1"
-
},
-
"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"
-
},
-
"peerDependenciesMeta": {
-
"@types/node": {
-
"optional": true
-
},
-
"jiti": {
-
"optional": true
-
},
-
"less": {
-
"optional": true
-
},
-
"lightningcss": {
-
"optional": true
-
},
-
"sass": {
-
"optional": true
-
},
-
"sass-embedded": {
-
"optional": true
-
},
-
"stylus": {
-
"optional": true
-
},
-
"sugarss": {
-
"optional": true
-
},
-
"terser": {
-
"optional": true
-
},
-
"tsx": {
-
"optional": true
-
},
-
"yaml": {
-
"optional": true
-
}
-
}
-
},
-
"node_modules/which": {
-
"version": "2.0.2",
-
"dev": true,
-
"license": "ISC",
-
"dependencies": {
-
"isexe": "^2.0.0"
-
},
-
"bin": {
-
"node-which": "bin/node-which"
-
},
-
"engines": {
-
"node": ">= 8"
-
}
-
},
-
"node_modules/word-wrap": {
-
"version": "1.2.5",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=0.10.0"
-
}
-
},
-
"node_modules/yallist": {
-
"version": "3.1.1",
-
"dev": true,
-
"license": "ISC"
-
},
-
"node_modules/yaml": {
-
"version": "2.7.0",
-
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
-
"integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
-
"license": "ISC",
-
"optional": true,
-
"peer": true,
-
"bin": {
-
"yaml": "bin.mjs"
-
},
-
"engines": {
-
"node": ">= 14"
-
}
-
},
-
"node_modules/yocto-queue": {
-
"version": "0.1.0",
-
"dev": true,
-
"license": "MIT",
-
"engines": {
-
"node": ">=10"
-
},
-
"funding": {
-
"url": "https://github.com/sponsors/sindresorhus"
-
}
-
},
-
"node_modules/zod": {
-
"version": "3.24.1",
-
"license": "MIT",
-
"funding": {
-
"url": "https://github.com/sponsors/colinhacks"
-
}
-
}
-
}
-
}
+40 -4
frontend/src/api/client.ts
···
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', {
···
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) => {
-
const response = await api.get<ClickStats[]>(`/links/${id}/clicks`);
-
return response.data;
+
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) => {
-
const response = await api.get<SourceStats[]>(`/links/${id}/sources`);
-
return response.data;
+
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 };
+82 -62
frontend/src/components/AuthForms.tsx
···
-
import { useState } from 'react'
+
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
···
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Form,
FormControl,
···
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(),
+
adminToken: z.string().optional(),
})
type FormValues = z.infer<typeof formSchema>
export function AuthForms() {
-
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login')
+
const [isFirstUser, setIsFirstUser] = useState<boolean | null>(null)
const { login, register } = useAuth()
const { toast } = useToast()
···
},
})
+
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 (activeTab === 'login') {
-
await login(values.email, values.password)
+
if (isFirstUser) {
+
await register(values.email, values.password, values.adminToken || '')
} else {
-
await register(values.email, values.password, values.adminToken)
+
await login(values.email, values.password)
}
form.reset()
} catch (err: any) {
···
}
}
+
if (isFirstUser === null) {
+
return <div>Loading...</div>
+
}
+
return (
<Card className="w-full max-w-md mx-auto p-6">
-
<Tabs value={activeTab} onValueChange={(value: string) => setActiveTab(value as 'login' | 'register')}>
-
<TabsList className="grid w-full grid-cols-2">
-
<TabsTrigger value="login">Login</TabsTrigger>
-
<TabsTrigger value="register">Register</TabsTrigger>
-
</TabsList>
+
<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>
-
<TabsContent value={activeTab}>
-
<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>
-
)}
-
/>
+
<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>
-
)}
-
/>
+
<FormField
+
control={form.control}
+
name="password"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Password</FormLabel>
+
<FormControl>
+
<Input type="password" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
-
{activeTab === 'register' && (
-
<FormField
-
control={form.control}
-
name="adminToken"
-
render={({ field }) => (
-
<FormItem>
-
<FormLabel>Admin Setup Token</FormLabel>
-
<FormControl>
-
<Input type="text" {...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">
-
{activeTab === 'login' ? 'Sign in' : 'Create account'}
-
</Button>
-
</form>
-
</Form>
-
</TabsContent>
-
</Tabs>
+
<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>
+
);
+
}
+45 -14
frontend/src/components/LinkList.tsx
···
-
import { useEffect, useState } from 'react'
+
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"
···
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast"
-
import { Copy, Trash2, BarChart2 } from "lucide-react"
+
import { Copy, Trash2, BarChart2, Pencil } from "lucide-react"
import {
Dialog,
DialogContent,
···
} from "@/components/ui/dialog"
import { StatisticsModal } from "./StatisticsModal"
+
import { EditModal } from './EditModal'
interface LinkListProps {
refresh?: number;
···
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) {
+
} catch (err: unknown) {
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
toast({
title: "Error",
-
description: "Failed to load links",
+
description: `Failed to load links: ${errorMessage}`,
variant: "destructive",
})
} finally {
setLoading(false)
}
-
}
+
}, [toast, setLinks, setLoading])
useEffect(() => {
fetchLinks()
-
}, [refresh]) // Re-fetch when refresh counter changes
+
}, [fetchLinks, refresh]) // Re-fetch when refresh counter changes
const handleDelete = async () => {
if (!deleteModal.linkId) return
···
toast({
description: "Link deleted successfully",
})
-
} catch (err) {
+
} catch (err: unknown) {
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
toast({
title: "Error",
-
description: "Failed to delete link",
+
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 = import.meta.env.VITE_API_URL || window.location.origin
+
const baseUrl = window.location.origin
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
toast({
-
description: "Link copied to clipboard",
+
description: (
+
<>
+
Link copied to clipboard
+
<br />
+
You can add ?source=TextHere to the end of the link to track the source of clicks
+
</>
+
),
})
}
···
</CardHeader>
<CardContent>
<div className="rounded-md border">
+
<Table>
<TableHeader>
<TableRow>
···
<TableHead className="hidden md:table-cell">Original URL</TableHead>
<TableHead>Clicks</TableHead>
<TableHead className="hidden md:table-cell">Created</TableHead>
-
<TableHead>Actions</TableHead>
+
<TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
···
<TableCell className="hidden md:table-cell">
{new Date(link.created_at).toLocaleDateString()}
</TableCell>
-
<TableCell>
-
<div className="flex gap-2">
+
<TableCell className="p-2 pr-4">
+
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
···
<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 })}
>
···
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}
+
/>
+
)}
</>
)
}
+160 -98
frontend/src/components/StatisticsModal.tsx
···
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
-
LineChart,
-
Line,
-
XAxis,
-
YAxis,
-
CartesianGrid,
-
Tooltip,
-
ResponsiveContainer,
+
LineChart,
+
Line,
+
XAxis,
+
YAxis,
+
CartesianGrid,
+
Tooltip,
+
ResponsiveContainer,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-
import { useState, useEffect } from "react";
+
import { toast } from "@/hooks/use-toast";
+
import { useState, useEffect, useMemo } from "react";
-
import { getLinkClickStats, getLinkSourceStats } from '../api/client';
-
import { ClickStats, SourceStats } from '../types/api';
+
import { getLinkClickStats, getLinkSourceStats } from "../api/client";
+
import { ClickStats, SourceStats } from "../types/api";
interface StatisticsModalProps {
-
isOpen: boolean;
-
onClose: () => void;
-
linkId: number;
+
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<ClickStats[]>([]);
-
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
-
const [loading, setLoading] = useState(true);
+
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),
-
]);
-
setClicksOverTime(clicksData);
-
setSourcesData(sourcesData);
-
} catch (error) {
-
console.error("Failed to fetch statistics:", error);
-
} finally {
-
setLoading(false);
-
}
-
};
+
useEffect(() => {
+
if (isOpen && linkId) {
+
const fetchData = async () => {
+
try {
+
setLoading(true);
+
const [clicksData, sourcesData] = await Promise.all([
+
getLinkClickStats(linkId),
+
getLinkSourceStats(linkId),
+
]);
-
fetchData();
-
}
-
}, [isOpen, linkId]);
+
// Enhance clicks data with source information
+
const enhancedClicksData = clicksData.map((clickData) => ({
+
...clickData,
+
sources: sourcesData.filter((source) => source.date === clickData.date),
+
}));
-
return (
-
<Dialog open={isOpen} onOpenChange={onClose}>
-
<DialogContent className="max-w-3xl">
-
<DialogHeader>
-
<DialogTitle>Link Statistics</DialogTitle>
-
</DialogHeader>
+
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);
+
}
+
};
-
{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 />
-
<Line
-
type="monotone"
-
dataKey="clicks"
-
stroke="#8884d8"
-
strokeWidth={2}
-
/>
-
</LineChart>
-
</ResponsiveContainer>
-
</div>
-
</CardContent>
-
</Card>
+
fetchData();
+
}
+
}, [isOpen, linkId]);
-
<Card>
-
<CardHeader>
-
<CardTitle>Top Sources</CardTitle>
-
</CardHeader>
-
<CardContent>
-
<ul className="space-y-2">
-
{sourcesData.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>
+
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>
+
);
}
+10
frontend/src/context/AuthContext.tsx
···
setUser(userData);
}
setIsLoading(false);
+
+
const handleUnauthorized = () => {
+
setUser(null);
+
};
+
+
window.addEventListener('unauthorized', handleUnauthorized);
+
+
return () => {
+
window.removeEventListener('unauthorized', handleUnauthorized);
+
};
}, []);
const login = async (email: string, password: string) => {
+1
frontend/src/types/api.ts
···
}
export interface SourceStats {
+
date: string;
source: string;
count: number;
}
+28 -15
frontend/vite.config.ts
···
import tailwindcss from '@tailwindcss/vite'
import path from "path"
-
export default defineConfig(() => ({
-
plugins: [react(), tailwindcss()],
-
server: {
-
proxy: {
-
'/api': {
-
target: process.env.VITE_API_URL || '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"),
+
},
},
-
},
-
},
-
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);
+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))
}
-
}
+
}
+
+583 -155
src/handlers.rs
···
use crate::{
error::AppError,
models::{
-
AuthResponse, Claims, ClickStats, CreateLink, Link, LoginRequest, RegisterRequest,
-
SourceStats, User, UserResponse,
+
AuthResponse, Claims, ClickStats, CreateLink, DatabasePool, Link, LoginRequest,
+
RegisterRequest, SourceStats, User, UserResponse,
},
AppState,
};
···
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.clone()
} else {
generate_short_code()
};
-
// Start transaction
-
let mut tx = state.db.begin().await?;
+
// 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?;
+
}
-
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?;
+
tx.commit().await?;
+
link
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().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)
+
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
+
}
+
};
-
tx.commit().await?;
-
Ok(HttpResponse::Created().json(link))
+
Ok(HttpResponse::Created().json(result))
}
fn validate_custom_code(code: &str) -> Result<(), AppError> {
···
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()));
}
···
.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?;
+
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?;
+
}
+
};
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", link.original_url))
···
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"),
+
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")
}
}
···
payload: web::Json<RegisterRequest>,
) -> Result<impl Responder, AppError> {
// Check if any users exist
-
let user_count = sqlx::query!("SELECT COUNT(*) as count FROM users")
-
.fetch_one(&state.db)
-
.await?
-
.count
-
.unwrap_or(0);
+
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 {
···
}
// Check if email already exists
-
let exists = sqlx::query!("SELECT id FROM users WHERE email = $1", payload.email)
-
.fetch_optional(&state.db)
-
.await?;
+
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()));
···
.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());
···
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?
-
.ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?;
+
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 =
···
}))
}
-
pub async fn delete_link(
+
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 = path.into_inner();
+
let link_id: i32 = path.into_inner();
-
// Start transaction
-
let mut tx = state.db.begin().await?;
+
// Validate the new URL if provided
+
validate_url(&payload.url)?;
-
// Verify the link belongs to the user
-
let link = sqlx::query!(
-
"SELECT id FROM links WHERE id = $1 AND user_id = $2",
-
link_id,
-
user.user_id
-
)
-
.fetch_optional(&mut *tx)
-
.await?;
+
// Validate custom code if provided
+
if let Some(ref custom_code) = payload.custom_code {
+
validate_custom_code(custom_code)?;
-
if link.is_none() {
-
return Err(AppError::NotFound);
+
// 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(),
+
));
+
}
}
-
// Delete associated clicks first due to foreign key constraint
-
sqlx::query!("DELETE FROM clicks WHERE link_id = $1", link_id)
-
.execute(&mut *tx)
-
.await?;
+
// 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?;
+
}
-
// Delete the link
-
sqlx::query!("DELETE FROM links WHERE id = $1", link_id)
-
.execute(&mut *tx)
-
.await?;
+
tx.commit().await?;
+
updated
+
}
+
};
-
tx.commit().await?;
+
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())
}
···
) -> Result<impl Responder, AppError> {
let link_id = path.into_inner();
-
// Verify the link belongs to the user
-
let link = sqlx::query!(
-
"SELECT id FROM links WHERE id = $1 AND user_id = $2",
-
link_id,
-
user.user_id
-
)
-
.fetch_optional(&state.db)
-
.await?;
+
// 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 = sqlx::query_as!(
-
ClickStats,
-
r#"
-
SELECT
-
DATE(created_at)::date as "date!",
-
COUNT(*)::bigint as "clicks!"
-
FROM clicks
-
WHERE link_id = $1
-
GROUP BY DATE(created_at)
-
ORDER BY DATE(created_at) ASC -- Changed from DESC to ASC
-
LIMIT 30
-
"#,
-
link_id
-
)
-
.fetch_all(&state.db)
-
.await?;
+
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))
}
···
let link_id = path.into_inner();
// Verify the link belongs to the user
-
let link = sqlx::query!(
-
"SELECT id FROM links WHERE id = $1 AND user_id = $2",
-
link_id,
-
user.user_id
-
)
-
.fetch_optional(&state.db)
-
.await?;
+
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 = sqlx::query_as!(
-
SourceStats,
-
r#"
-
SELECT
-
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 query_source
-
ORDER BY COUNT(*) DESC
-
LIMIT 10
-
"#,
-
link_id
-
)
-
.fetch_all(&state.db)
-
.await?;
+
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
+
})))
+
}
+94 -10
src/lib.rs
···
+
use anyhow::Result;
use rand::Rng;
-
use sqlx::PgPool;
+
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 check_and_generate_admin_token(pool: &sqlx::PgPool) -> anyhow::Result<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 = sqlx::query!("SELECT COUNT(*) as count FROM users")
-
.fetch_one(pool)
-
.await?
-
.count
-
.unwrap_or(0);
+
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 {
-
// Generate a random token using simple characters
let token: String = (0..32)
.map(|_| {
let idx = rand::thread_rng().gen_range(0..62);
···
})
.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("admin-setup-token.txt")?;
+
let mut file = File::create(token_path)?;
writeln!(file, "{}", token)?;
info!("No users found - generated admin setup token");
+165 -12
src/main.rs
···
use actix_cors::Cors;
use actix_web::{web, App, HttpResponse, HttpServer};
use anyhow::Result;
+
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::PgPoolOptions;
-
use tracing::info;
+
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 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<()> {
// Load environment variables from .env file
···
// 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,
+
};
+
+
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
+
}
+
};
+
+
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?;
···
"/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)))
+77 -5
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 chrono::NaiveDate;
-
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 {
···
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize
-
+ 24 * 60 * 60; // 24 hours from now
+
+ 14 * 24 * 60 * 60; // 2 weeks from now
Self { sub: user_id, exp }
}
···
#[derive(sqlx::FromRow, Serialize)]
pub struct ClickStats {
-
pub date: NaiveDate,
+
pub date: String,
pub clicks: i64,
}
#[derive(sqlx::FromRow, Serialize)]
pub struct SourceStats {
+
pub date: String,
pub source: String,
pub count: i64,
}