commit d4d093e779a6b0fec2ce81ed7a3fc64bf0947cb3 Author: etiennecollin Date: Tue Aug 5 15:40:56 2025 +0200 Initial commit fix: double api call to get voucher details style: removed ugly tab background diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0753398 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +README.md +.env +compose.yaml +assets + +Dockerfile +.git +.gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3323b34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9d5d2ae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,111 @@ +# ============================================================================== +# Base images +# ============================================================================== +FROM node:24.3-alpine AS node-base +FROM rust:1.88-alpine AS rust-base + +# ============================================================================== +# Backend dependencies +# ============================================================================== +FROM rust-base AS rust-deps + +RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static + +WORKDIR /app + +COPY ./backend/Cargo.toml ./backend/Cargo.lock ./ +# Create dummy src to satisfy cargo +RUN mkdir ./src && echo "fn main() {}" > ./src/main.rs + +ENV OPENSSL_STATIC=1 + +# Build dependencies only (cache them) +RUN cargo build --release && rm -rf ./src/ + +# ============================================================================== +# Backend build +# ============================================================================== +FROM rust-deps AS rust-builder + +# Now copy real source +COPY ./backend/src ./src + +# Build only our code (dependencies already built) +RUN cargo build --release + +# ============================================================================== +# Frontend dependencies +# ============================================================================== +FROM node-base AS node-deps + +RUN apk add --no-cache libc6-compat + +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY ./frontend/package.json ./frontend/yarn.lock* ./frontend/package-lock.json* ./frontend/pnpm-lock.yaml* ./frontend/.npmrc* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# ============================================================================== +# Frontend build +# ============================================================================== +FROM node-base AS node-builder + +WORKDIR /app + +COPY --from=node-deps /app/node_modules ./node_modules +COPY ./frontend ./ + +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# ============================================================================== +# Runner +# ============================================================================== +FROM alpine:3.22 AS runtime +RUN apk add --no-cache ca-certificates wget nodejs + +WORKDIR /app + +RUN addgroup -g 1001 -S appgroup && \ + adduser -S appuser -u 1001 -G appgroup + +# Copy run wrapper script +COPY ./scripts/run_wrapper.sh ./ +RUN chmod +x run_wrapper.sh + +# Create healthcheck script +COPY ./scripts/healthcheck.sh /usr/local/bin/healthcheck.sh +RUN chmod +x /usr/local/bin/healthcheck.sh + +# Copy backend +COPY --from=rust-builder --chown=appuser:appgroup /app/target/release/backend ./backend + +# Copy frontend +COPY --from=node-builder --chown=appuser:appgroup /app/public* ./frontend/public +COPY --from=node-builder --chown=appuser:appgroup /app/.next/standalone ./frontend +COPY --from=node-builder --chown=appuser:appgroup /app/.next/static ./frontend/.next/static + +USER appuser + +ENV FRONTEND_BIND_HOST="0.0.0.0" +ENV FRONTEND_BIND_PORT="3000" +ENV FRONTEND_TO_BACKEND_URL="http://127.0.0.1" +ENV BACKEND_BIND_HOST="127.0.0.1" +ENV BACKEND_BIND_PORT="8080" + +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD /usr/local/bin/healthcheck.sh + +CMD ["./run_wrapper.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a60f276 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Etienne Collin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..deddd50 --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# WiFi Voucher Manager + +[![Docker Image Version (latest by date)](https://img.shields.io/docker/v/etiennecollin/unifi-voucher-manager?sort=semver&label=Version&logo=docker&color=blue)] + +A modern, touch-friendly web application for managing WiFi vouchers on UniFi controllers. +Perfect for businesses, cafes, hotels, and home networks that need to provide guest WiFi access. + +![WiFi Voucher Manager](./assets/frontend.png) + + + +- [✨ Features](#-features) + - [🎫 Voucher Management](#-voucher-management) + - [🎨 Modern Interface](#-modern-interface) + - [πŸ”§ Technical Features](#-technical-features) + - [πŸ— Architecture](#-architecture) +- [πŸš€ Quick Start](#-quick-start) + - [🐳 Using Docker Compose (Recommended)](#-using-docker-compose-recommended) + - [βš™οΈ Without Docker](#-without-docker) +- [βš™οΈ Configuration](#-configuration) + - [Environment Variables](#environment-variables) + - [Getting UniFi API Credentials](#getting-unifi-api-credentials) +- [🀝 Contributing](#-contributing) + - [Development Guidelines](#development-guidelines) + - [Code Style](#code-style) +- [πŸ› Troubleshooting](#-troubleshooting) + - [Common Issues](#common-issues) + - [Vouchers not appearing or connection issue to UniFi controller](#vouchers-not-appearing-or-connection-issue-to-unifi-controller) + - [Application won't start](#application-wont-start) + - [Getting Help](#getting-help) + + + +## ✨ Features + +### 🎫 Voucher Management + +- **Quick Create** - Generate guest vouchers with preset durations (1 hour to 1 week) +- **Custom Create** - Full control over voucher parameters: + - Custom name + - Duration (minutes to days) + - Guest count limits + - Data usage limits + - Upload/download speed limits +- **View All Vouchers** - Browse and search existing vouchers by name +- **Search Vouchers** - Search vouchers by name +- **Bulk Operations** - Select and delete multiple vouchers +- **Auto-cleanup** - Remove expired vouchers with a single click + +### 🎨 Modern Interface + +- **Touch-Friendly** – Optimized for tablet, mobile, and desktop. +- **Dark/Light Mode** – Follows system preference, with manual override. +- **Responsive Design** - Works seamlessly across all screen sizes +- **Smooth Animations** – Semantic transitions for polished UX. +- **Real-time Notifications** - Instant feedback for all operations + +### πŸ”§ Technical Features + +- **Docker Ready** - Easy deployment with Docker Compose and included healthcheck +- **UniFi Integration** - Direct API connection to UniFi controllers + +#### πŸ— Architecture + +This application is built with a clear separation of concerns: + +1. **Frontend**: A Next.js 14 application (TypeScript + Tailwind CSS) that provides a responsive, touch-friendly UI. +2. **Backend**: A Rust service powered by [Axum](https://github.com/tokio-rs/axum) that exposes a JSON API. +3. **UniFi Controller**: The Axum backend securely communicates with your UniFi controller’s API, isolating API keys from the user-facing frontend. + +```text + { DOCKER } +[User Browser] <––– HTTP/HTTPS –––> [Next.js Frontend] <––– HTTP/HTTPS –––> [Axum Rust Backend] <––– HTTPS –––> [UniFi Controller] +``` + +- The frontend only knows about the backend API endpoint. +- All UniFi credentials and site IDs are stored on the backend. +- This isolation limits the scope of user actions and protects sensitive API keys. + +## πŸš€ Quick Start + +### 🐳 Using Docker Compose (Recommended) + +1. **Create the configuration files** + +```bash +# Download the compose file +curl -o compose.yaml https://raw.githubusercontent.com/etiennecollin/unifi-voucher-manager/main/compose.yaml +``` + +2. **Configure your environment** + +Set the required environment variables (see [Environment Variables](#environment-variables)) in the `compose.yaml` file. + +3. **Start the application** + +```bash +docker compose up -d --force-recreate +``` + +4. **Access the interface** + +Open your browser to `http://localhost:3000`. + +### βš™οΈ Without Docker + +1. **Install the dependencies** + +- `rust >= 1.88.0` +- `nodejs >= 24.3.0` +- `npm >= 11.4.2` + +2. **Configure your environment** + +In your shell, set the required environment variables (see [Environment Variables](#environment-variables)) + +3. **Start the frontend and backend** + +```bash +# Backend +cd backend && cargo run -r + +# Frontend (development) +cd frontend && npm install && npm run dev + +# Frontend (release) +cd frontend && npm ci && npm run build && npm run start +``` + +## βš™οΈ Configuration + +### Environment Variables + +| Variable | Type | Description | Example (default if optional) | +| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `UNIFI_CONTROLLER_URL` | Required | URL to your UniFi controller with protocol. | `https://unifi.example.com:8443` | +| `UNIFI_API_KEY` | Required | API Key for your UniFi controller. | `abc123...` | +| `UNIFI_SITE_ID` | Optional | Site ID of your UniFi controller. Using the value `default`, the backend will try to fetch the ID of the default site. | `default` (default) | +| `FRONTEND_BIND_HOST` | Optional | Address on which the frontend server binds. | `0.0.0.0` (default) | +| `FRONTEND_BIND_PORT` | Optional | Port on which the frontend server binds. | `3000` (default) | +| `FRONTEND_TO_BACKEND_URL` | Optional | URL where the frontend will make its API requests to the backend. | `http://127.0.0.1` (default) | +| `BACKEND_BIND_HOST` | Optional | Address on which the server binds. | `127.0.0.1` (default) | +| `BACKEND_BIND_PORT` | Optional | Port on which the backend server binds. | `8080` (default) | +| `TIMEZONE` | Optional | Server [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). | `UTC` (default) | +| `RUST_LOG` | Optional | Log level of the Rust backend. | `info`(default) | + +### Getting UniFi API Credentials + +1. **Access your UniFi Controller** +2. **Navigate to Settings -> Control Plane -> Integration** +3. **Create a new API key** by giving it a name and an expiration. +4. **Find your Site ID** in the controller URL or on [unifi.ui.com](https://unifi.ui.com) + +## 🀝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +### Development Guidelines + +1. **Fork the repository** +2. **Create a feature branch**: `git checkout -b feature/amazing-feature` +3. **Make your changes** with proper TypeScript types +4. **Update documentation** as needed +5. **Commit changes**: `git commit -m 'feat: Add amazing feature'` +6. **Push to branch**: `git push origin feature/amazing-feature` +7. **Open a Pull Request** + +### Code Style + +- Use TypeScript for all new code +- Follow the existing Tailwind CSS semantic utility patterns +- Ensure components are touch-friendly and responsive +- Add proper error handling and user feedback + +## πŸ› Troubleshooting + +### Common Issues + +#### Vouchers not appearing or connection issue to UniFi controller + +- Verify `UNIFI_CONTROLLER_URL` is correct and accessible +- Verify `UNIFI_SITE_ID` matches your controller's site +- Check if UniFi controller is running and reachable +- Ensure API key is valid +- Ensure the site has hotspot/guest portal enabled + +#### Application won't start + +- Check all environment variables are set +- Verify Docker container has network access to UniFi controller +- Check logs: `docker compose logs unifi-voucher-manager` + +### Getting Help + +- Check the [Issues](https://github.com/etiennecollin/unifi-voucher-manager/issues) page +- Create a new issue with detailed information about your problem +- Include relevant logs and environment details (redact sensitive information) + +--- + +**⭐ If this project helped you, please consider giving it a star!** diff --git a/assets/frontend.png b/assets/frontend.png new file mode 100644 index 0000000..8d2dc0d Binary files /dev/null and b/assets/frontend.png differ diff --git a/backend/.cargo/config.toml b/backend/.cargo/config.toml new file mode 100644 index 0000000..ddff440 --- /dev/null +++ b/backend/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["-C", "target-cpu=native"] diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c27cc9d --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +target +README.md + +.dockerignore +.gitignore diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +target diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..82cc55f --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,2193 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backend" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "chrono-tz", + "reqwest", + "serde", + "serde_json", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[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 = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "h2" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.0", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +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.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +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_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.5.10", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "redox_syscall" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251471db004e509f4e75a62cca9435365b5ec7bcdff530d612ac7c87c44a792" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069a8df149a16b1a12dcc31497c3396a173844be3cac4bd40c9e7671fef96671" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[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 = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.141" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43864ed400b6043a4757a25c7a64a8efde741aed79a056a2fb348a406701bb35" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2 0.6.0", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[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-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" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..8bba608 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "backend" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8.4" +chrono = { version = "0.4.41" } +chrono-tz = "0.10.4" +reqwest = { version = "0.12.22", features = ["json", "rustls-tls"] } +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.141" +tokio = { version = "1.47.0", features = ["full"] } +tower = "0.5.2" # Remove?? +tower-http = { version = "0.6.6", features = ["cors"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + +[profile.release] +opt-level = "z" +codegen-units = 1 +lto = "fat" +panic = "abort" +strip = "symbols" + diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs new file mode 100644 index 0000000..86f353c --- /dev/null +++ b/backend/src/handlers.rs @@ -0,0 +1,90 @@ +use crate::{models::*, unifi_api::*}; +use axum::{extract::Query, http::StatusCode, response::Json}; +use tracing::{debug, error}; + +pub async fn get_vouchers_handler() -> Result, StatusCode> { + debug!("Received request to get vouchers"); + let client = UNIFI_API.get().expect("UnifiAPI not initialized"); + match client.get_all_vouchers().await { + Ok(response) => Ok(Json(response)), + Err(e) => { + error!("Failed to get vouchers: {}", e); + Err(e) + } + } +} + +pub async fn get_newest_voucher_handler() -> Result>, StatusCode> { + debug!("Received request to get newest voucher"); + let client = UNIFI_API.get().expect("UnifiAPI not initialized"); + match client.get_newest_voucher().await { + Ok(voucher) => Ok(Json(voucher)), + Err(e) => { + error!("Failed to get newest voucher: {}", e); + Err(e) + } + } +} + +pub async fn get_voucher_details_handler( + Query(params): Query, +) -> Result, StatusCode> { + debug!("Received request to get voucher details"); + let client = UNIFI_API.get().expect("UnifiAPI not initialized"); + match client.get_voucher_details(params.id).await { + Ok(voucher) => Ok(Json(voucher)), + Err(e) => { + error!("Failed to get voucher details: {}", e); + Err(e) + } + } +} + +pub async fn create_voucher_handler( + Json(request): Json, +) -> Result, StatusCode> { + debug!("Received request to create voucher"); + let client = UNIFI_API.get().expect("UnifiAPI not initialized"); + match client.create_voucher(request.clone()).await { + Ok(response) => Ok(Json(response)), + Err(e) => { + error!("Failed to create voucher: {}", e); + Err(e) + } + } +} + +pub async fn delete_selected_handler( + Query(params): Query, +) -> Result, StatusCode> { + debug!("Received request to delete selected vouchers"); + let client = UNIFI_API.get().expect("UnifiAPI not initialized"); + let ids = params.ids.split(',').map(|s| s.to_string()).collect(); + match client.delete_vouchers_by_ids(ids).await { + Ok(response) => Ok(Json(response)), + Err(e) => { + error!("Failed to delete selected vouchers: {}", e); + Err(e) + } + } +} + +pub async fn delete_expired_handler() -> Result, StatusCode> { + debug!("Received request to delete expired vouchers"); + let client = UNIFI_API.get().expect("UnifiAPI not initialized"); + match client.delete_expired_vouchers().await { + Ok(response) => Ok(Json(response)), + Err(e) => { + error!("Failed to delete expired vouchers: {}", e); + Err(e) + } + } +} + +pub async fn health_check_handler() -> Result, StatusCode> { + debug!("Received health check request"); + let response = HealthCheckResponse { + status: "ok".to_string(), + }; + Ok(Json(response)) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..8c1b739 --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,70 @@ +use std::{env, sync::OnceLock}; + +use chrono_tz::Tz; +use tracing::{error, info}; + +pub mod handlers; +pub mod models; +pub mod unifi_api; + +const DEFAULT_BACKEND_BIND_HOST: &str = "127.0.0.1"; +const DEFAULT_BACKEND_BIND_PORT: u16 = 8080; +const DEFAULT_UNIFI_SITE_ID: &str = "default"; + +pub static ENVIRONMENT: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone)] +pub struct Environment { + pub unifi_controller_url: String, + pub unifi_site_id: String, + pub unifi_api_key: String, + pub backend_bind_host: String, + pub backend_bind_port: u16, + pub timezone: Tz, +} + +impl Environment { + pub fn try_new() -> Result { + let unifi_controller_url: String = + env::var("UNIFI_CONTROLLER_URL").map_err(|e| format!("UNIFI_CONTROLLER_URL: {e}"))?; + let unifi_api_key: String = + env::var("UNIFI_API_KEY").map_err(|e| format!("UNIFI_API_KEY: {e}"))?; + let unifi_site_id: String = + env::var("UNIFI_SITE_ID").unwrap_or(DEFAULT_UNIFI_SITE_ID.to_owned()); + + let backend_bind_host: String = + env::var("BACKEND_BIND_HOST").unwrap_or(DEFAULT_BACKEND_BIND_HOST.to_owned()); + let backend_bind_port: u16 = match env::var("BACKEND_BIND_PORT") { + Ok(port_str) => port_str + .parse() + .map_err(|e| format!("Invalid BACKEND_BIND_PORT: {e}"))?, + Err(_) => DEFAULT_BACKEND_BIND_PORT, + }; + + let timezone: Tz = match env::var("TIMEZONE") { + Ok(s) => match s.parse() { + Ok(tz) => { + info!("Using timezone: {}", s); + tz + } + Err(_) => { + error!("Using UTC, could not parse timezone: {}", s); + Tz::UTC + } + }, + Err(_) => { + info!("TIMEZONE environment variable not set, defaulting to UTC"); + Tz::UTC + } + }; + + Ok(Self { + unifi_controller_url, + unifi_site_id, + unifi_api_key, + backend_bind_host, + backend_bind_port, + timezone, + }) + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..01453f0 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,70 @@ +use axum::{ + Router, + http::{self, Method}, + routing::{delete, get, post}, +}; +use backend::{ + ENVIRONMENT, Environment, + handlers::*, + unifi_api::{UNIFI_API, UnifiAPI}, +}; +use tower_http::cors::{Any, CorsLayer}; +use tracing::{error, info, level_filters::LevelFilter}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() { + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(), // use RUST_LOG env variable when running + ) + .init(); + + let env = match Environment::try_new() { + Ok(env) => env, + Err(e) => { + error!("Failed to load environment variables: {e}"); + std::process::exit(1); + } + }; + ENVIRONMENT + .set(env) + .expect("Failed to set environment variables"); + + let unifi_api = match UnifiAPI::new().await { + Ok(api) => api, + Err(_) => { + error!("Failed to initialize UnifiAPI wrapper"); + std::process::exit(1); + } + }; + UNIFI_API.set(unifi_api).expect("Failed to set UnifiAPI"); + + let cors = CorsLayer::new() + .allow_headers([http::header::CONTENT_TYPE]) + .allow_methods([Method::POST, Method::GET, Method::DELETE]) + .allow_origin(Any); + + let app = Router::new() + .route("/api/health", get(health_check_handler)) + .route("/api/vouchers", get(get_vouchers_handler)) + .route("/api/vouchers", post(create_voucher_handler)) + .route("/api/vouchers/details", get(get_voucher_details_handler)) + .route("/api/vouchers/expired", delete(delete_expired_handler)) + .route("/api/vouchers/newest", get(get_newest_voucher_handler)) + .route("/api/vouchers/selected", delete(delete_selected_handler)) + .layer(cors); + + let environment = ENVIRONMENT.get().expect("Environment not set"); + let bind_address = format!( + "{}:{}", + environment.backend_bind_host, environment.backend_bind_port + ); + let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap(); + info!("Server running on http://{}", bind_address); + + axum::serve(listener, app).await.unwrap(); +} diff --git a/backend/src/models.rs b/backend/src/models.rs new file mode 100644 index 0000000..232ec6f --- /dev/null +++ b/backend/src/models.rs @@ -0,0 +1,108 @@ +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Voucher { + id: String, + #[serde(rename = "createdAt")] + pub created_at: String, + name: String, + code: String, + #[serde(rename = "authorizedGuestLimit")] + authorized_guest_limit: Option, + #[serde(rename = "authorizedGuestCount")] + authorized_guest_count: u64, + #[serde(rename = "activatedAt")] + pub activated_at: Option, + #[serde(rename = "expiresAt")] + pub expires_at: Option, + expired: bool, + #[serde(rename = "timeLimitMinutes")] + time_limit_minutes: u64, + #[serde(rename = "dataUsageLimitMBytes")] + data_usage_limit_mbytes: Option, + #[serde(rename = "rxRateLimitKbps")] + rx_rate_limit_kbps: Option, + #[serde(rename = "txRateLimitKbps")] + tx_rate_limit_kbps: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateVoucherRequest { + count: u32, + name: String, + #[serde(rename = "authorizedGuestLimit")] + authorized_guest_limit: Option, + #[serde(rename = "timeLimitMinutes")] + time_limit_minutes: u64, + #[serde(rename = "dataUsageLimitMBytes")] + data_usage_limit_mbytes: Option, + #[serde(rename = "rxRateLimitKbps")] + rx_rate_limit_kbps: Option, + #[serde(rename = "txRateLimitKbps")] + tx_rate_limit_kbps: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateVoucherResponse { + pub vouchers: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetVouchersResponse { + pub data: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeleteResponse { + #[serde(rename = "vouchersDeleted")] + pub vouchers_deleted: u32, +} + +#[derive(Debug, Serialize)] +pub struct HealthCheckResponse { + pub status: String, +} + +#[derive(Debug, Deserialize)] +pub struct DeleteRequest { + pub ids: String, +} + +#[derive(Debug, Deserialize)] +pub struct DetailsRequest { + pub id: String, +} + +#[derive(Debug, Deserialize)] +pub struct Site { + pub id: String, + #[serde(rename = "internalReference")] + internal_reference: String, + pub name: String, +} + +#[derive(Debug, Deserialize)] +pub struct GetSitesResponse { + offset: u64, + limit: u32, + count: u32, + #[serde(rename = "totalCount")] + total_count: u32, + pub data: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ErrorResponse { + #[serde(rename = "statusCode")] + pub status_code: u32, + #[serde(rename = "statusName")] + pub status_name: String, + pub message: String, + timestamp: String, + #[serde(rename = "requestPath")] + request_path: String, + #[serde(rename = "requestId")] + request_id: String, +} diff --git a/backend/src/unifi_api.rs b/backend/src/unifi_api.rs new file mode 100644 index 0000000..7220ba4 --- /dev/null +++ b/backend/src/unifi_api.rs @@ -0,0 +1,278 @@ +use chrono::DateTime; +use chrono_tz::Tz; +use reqwest::{Client, ClientBuilder, StatusCode}; +use std::{sync::OnceLock, time::Duration}; +use tracing::{error, info, warn}; + +use crate::{ + ENVIRONMENT, Environment, + models::{ + CreateVoucherRequest, CreateVoucherResponse, DeleteResponse, ErrorResponse, + GetSitesResponse, GetVouchersResponse, Voucher, + }, +}; + +const UNIFI_API_ROUTE: &str = "proxy/network/integration/v1/sites"; +const DATE_TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S"; + +pub static UNIFI_API: OnceLock = OnceLock::new(); + +enum RequestType { + Get, + Post, + Delete, +} + +#[derive(Debug, Clone)] +pub struct UnifiAPI<'a> { + client: Client, + sites_api_url: String, + voucher_api_url: String, + environment: &'a Environment, +} + +impl<'a> UnifiAPI<'a> { + pub async fn new() -> Result { + let environment: &Environment = ENVIRONMENT.get().expect("Environment not set"); + + let mut headers = reqwest::header::HeaderMap::with_capacity(2); + headers.insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("application/json"), + ); + headers.insert( + "X-API-Key", + environment + .unifi_api_key + .parse() + .expect("Could not parse API Key"), + ); + + let client = ClientBuilder::new() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .default_headers(headers) + .use_rustls_tls() + .build() + .expect("Failed to build UniFi reqwest client"); + + let mut unifi_api = Self { + client, + sites_api_url: format!("{}/{}", environment.unifi_controller_url, UNIFI_API_ROUTE), + voucher_api_url: String::new(), + environment, + }; + + let site_id = match environment.unifi_site_id.to_lowercase().as_str() { + "default" => { + info!("Trying to fetch the default site ID from UniFi controller..."); + let id = match unifi_api.get_default_site_id().await { + Ok(id) => id, + Err(e) => { + error!("Failed to fetch default site ID: {}", e); + return Err(()); + } + }; + info!("Default site ID found: {}", id); + id + } + _ => environment.unifi_site_id.clone(), + }; + + unifi_api.voucher_api_url = + format!("{}/{}/hotspot/vouchers", unifi_api.sites_api_url, site_id); + + Ok(unifi_api) + } + + async fn make_request< + T: serde::ser::Serialize + Sized, + U: serde::de::DeserializeOwned + Sized, + >( + &self, + request_type: RequestType, + url: &str, + body: Option<&T>, + ) -> Result { + // Make request + let response_result = match request_type { + RequestType::Get => self.client.get(url).send().await, + RequestType::Post => { + if let Some(b) = body { + self.client.post(url).json(b).send().await + } else { + error!("Body is required for POST requests"); + return Err(StatusCode::BAD_REQUEST); + } + } + RequestType::Delete => self.client.delete(url).send().await, + }; + + // Check if the request was successful + let response = match response_result { + Ok(resp) => resp, + Err(e) => { + let status = e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + error!("Request failed with status {}: {}", status, e.without_url()); + return Err(status); + } + }; + + // The request was successful, now check the status code + let clean_response = match response.error_for_status_ref().is_ok() { + true => response, + false => { + let status = response.status(); + error!("Request failed with status: {}", status); + + if let Ok(body) = response.json::().await { + error!("Error response message: {}", body.message); + } else { + error!("Failed to parse error response body"); + } + return Err(status); + } + }; + + // It's a successful response, now parse the JSON + clean_response.json::().await.map_err(|e| { + error!("Failed to parse response JSON: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + }) + } + + fn process_voucher(&self, voucher: &mut Voucher) { + voucher.created_at = format_unifi_date(&voucher.created_at, &self.environment.timezone); + if let Some(activated_at) = &mut voucher.activated_at { + *activated_at = format_unifi_date(activated_at, &self.environment.timezone); + } + if let Some(expires_at) = &mut voucher.expires_at { + *expires_at = format_unifi_date(expires_at, &self.environment.timezone); + } + } + + fn process_vouchers(&self, mut vouchers: Vec) -> Vec { + vouchers.iter_mut().for_each(|voucher| { + self.process_voucher(voucher); + }); + vouchers + } + + async fn get_default_site_id(&self) -> Result { + let url = format!( + "{}?filter=or(internalReference.eq('default'),name.eq('Default'))", + self.sites_api_url + ); + let result: GetSitesResponse = self + .make_request(RequestType::Get, &url, None::<&()>) + .await?; + + if result.data.is_empty() { + error!("No default site found on the UniFi controller"); + error!( + "Please manually set the `UNIFI_SITE_ID` environment variable to a valid site ID." + ); + return Err(StatusCode::NOT_FOUND); + } + + let id = result.data[0].id.to_owned(); + + Ok(id) + } + + pub async fn get_all_vouchers(&self) -> Result { + let mut result: GetVouchersResponse = self + .make_request(RequestType::Get, &self.voucher_api_url, None::<&()>) + .await?; + result.data = self.process_vouchers(result.data); + Ok(result) + } + + pub async fn get_newest_voucher(&self) -> Result, StatusCode> { + let response = self.get_all_vouchers().await?; + + if response.data.is_empty() { + warn!("No vouchers found when fetching the newest voucher"); + return Err(StatusCode::NOT_FOUND); + } + + // Find the newest voucher + let newest = response + .data + .iter() + .max_by_key(|voucher| { + DateTime::parse_from_str(&voucher.created_at, DATE_TIME_FORMAT) + .unwrap_or_else(|_| DateTime::UNIX_EPOCH.fixed_offset()) + }) + .cloned(); + + Ok(newest) + } + + pub async fn get_voucher_details(&self, id: String) -> Result { + let url = format!("{}/{}", self.voucher_api_url, id); + + let mut result: Voucher = self + .make_request(RequestType::Get, &url, None::<&()>) + .await?; + + self.process_voucher(&mut result); + Ok(result) + } + + pub async fn create_voucher( + &self, + request: CreateVoucherRequest, + ) -> Result { + let mut result: CreateVoucherResponse = self + .make_request(RequestType::Post, &self.voucher_api_url, Some(&request)) + .await?; + result.vouchers = self.process_vouchers(result.vouchers); + Ok(result) + } + + pub async fn delete_vouchers_by_ids( + &self, + ids: Vec, + ) -> Result { + if ids.is_empty() || (ids.len() == 1 && ids[0].is_empty()) { + return Ok(DeleteResponse { + vouchers_deleted: 0, + }); + } + + let filter_expr = ids + .iter() + .map(|id| format!("id.eq({id})")) + .collect::>() + .join(","); + + let url = if ids.len() == 1 { + format!("{}?filter={}", self.voucher_api_url, filter_expr) + } else { + format!("{}?filter=or({})", self.voucher_api_url, filter_expr) + }; + + self.make_request(RequestType::Delete, &url, None::<&()>) + .await + } + + pub async fn delete_expired_vouchers(&self) -> Result { + let url = format!("{}?filter=expired.eq(true)", self.voucher_api_url); + self.make_request(RequestType::Delete, &url, None::<&()>) + .await + } +} + +fn format_unifi_date(rfc3339_string: &str, target_timezone: &Tz) -> String { + match DateTime::parse_from_rfc3339(rfc3339_string) { + Ok(dt) => { + let local_time = dt.with_timezone(target_timezone); + local_time.format(DATE_TIME_FORMAT).to_string() + } + Err(_) => { + error!("Failed to parse RFC3339 date: {}", rfc3339_string); + rfc3339_string.to_string() + } + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..4402096 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,20 @@ +--- +services: + unifi-voucher-manager: + image: etiennecollin/unifi-voucher-manager + # To build the image yourself + # build: + # context: "./" + # target: "runtime" + # dockerfile: "./Dockerfile" + container_name: "unifi-voucher-manager" + restart: "unless-stopped" + ports: + - "3000:3000" + # To put the environment variables in a `.env` file, uncomment the env_file + # section and comment the environment section. + # env_file: + # - ".env" + environment: + UNIFI_CONTROLLER_URL: "URL to your Unifi controller with protocol. Example: 'https://unifi.example.com:8443' or 'http://192.168.0.1'" + UNIFI_API_KEY: "API Key for your Unifi controller" diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..1e4316e --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +npm-debug.log +README.md +.next + +.dockerignore +.gitignore diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..9b848d7 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,19 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +The page auto-updates as you edit files. diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..68a6c64 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0709bae --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1735 @@ +{ + "name": "unifi-voucher-manager", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "unifi-voucher-manager", + "version": "0.1.0", + "dependencies": { + "next": "15.4.5", + "react": "19.1.0", + "react-dom": "19.1.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "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/@emnapi/runtime": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", + "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", + "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.4" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.5.tgz", + "integrity": "sha512-ruM+q2SCOVCepUiERoxOmZY9ZVoecR3gcXNwCYZRvQQWRjhOiPJGmQ2fAiLR6YKWXcSAh7G79KEFxN3rwhs4LQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.5.tgz", + "integrity": "sha512-84dAN4fkfdC7nX6udDLz9GzQlMUwEMKD7zsseXrl7FTeIItF8vpk1lhLEnsotiiDt+QFu3O1FVWnqwcRD2U3KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.5.tgz", + "integrity": "sha512-CL6mfGsKuFSyQjx36p2ftwMNSb8PQog8y0HO/ONLdQqDql7x3aJb/wB+LA651r4we2pp/Ck+qoRVUeZZEvSurA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.5.tgz", + "integrity": "sha512-1hTVd9n6jpM/thnDc5kYHD1OjjWYpUJrJxY4DlEacT7L5SEOXIifIdTye6SQNNn8JDZrcN+n8AWOmeJ8u3KlvQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.5.tgz", + "integrity": "sha512-4W+D/nw3RpIwGrqpFi7greZ0hjrCaioGErI7XHgkcTeWdZd146NNu1s4HnaHonLeNTguKnL2Urqvj28UJj6Gqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.5.tgz", + "integrity": "sha512-N6Mgdxe/Cn2K1yMHge6pclffkxzbSGOydXVKYOjYqQXZYjLCfN/CuFkaYDeDHY2VBwSHyM2fUjYBiQCIlxIKDA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.5.tgz", + "integrity": "sha512-YZ3bNDrS8v5KiqgWE0xZQgtXgCTUacgFtnEgI4ccotAASwSvcMPDLua7BWLuTfucoRv6mPidXkITJLd8IdJplQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.5.tgz", + "integrity": "sha512-9Wr4t9GkZmMNcTVvSloFtjzbH4vtT4a8+UHqDoVnxA5QyfWe6c5flTH1BIWPGNWSUlofc8dVJAE7j84FQgskvQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.5.tgz", + "integrity": "sha512-voWk7XtGvlsP+w8VBz7lqp8Y+dYw/MTI4KeS0gTVtfdhdJ5QwhXLmNrndFOin/MDoCvUaLWMkYKATaCoUkt2/A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", + "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.11" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", + "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-x64": "4.1.11", + "@tailwindcss/oxide-freebsd-x64": "4.1.11", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-x64-musl": "4.1.11", + "@tailwindcss/oxide-wasm32-wasi": "4.1.11", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", + "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", + "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", + "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", + "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", + "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", + "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", + "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", + "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", + "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", + "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@emnapi/wasi-threads": "^1.0.2", + "@napi-rs/wasm-runtime": "^0.2.11", + "@tybys/wasm-util": "^0.9.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", + "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", + "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz", + "integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.11", + "@tailwindcss/oxide": "4.1.11", + "postcss": "^8.4.41", + "tailwindcss": "4.1.11" + } + }, + "node_modules/@types/node": { + "version": "20.19.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", + "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", + "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", + "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001731", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", + "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", + "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/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", + "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT", + "optional": true + }, + "node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/next": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/next/-/next-15.4.5.tgz", + "integrity": "sha512-nJ4v+IO9CPmbmcvsPebIoX3Q+S7f6Fu08/dEWu0Ttfa+wVwQRh9epcmsyCPjmL2b8MxC+CkBR97jgDhUUztI3g==", + "license": "MIT", + "dependencies": { + "@next/env": "15.4.5", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.4.5", + "@next/swc-darwin-x64": "15.4.5", + "@next/swc-linux-arm64-gnu": "15.4.5", + "@next/swc-linux-arm64-musl": "15.4.5", + "@next/swc-linux-x64-gnu": "15.4.5", + "@next/swc-linux-x64-musl": "15.4.5", + "@next/swc-win32-arm64-msvc": "15.4.5", + "@next/swc-win32-x64-msvc": "15.4.5", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "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.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.4", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", + "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..44bb26f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "unifi-voucher-manager", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "react": "19.1.0", + "react-dom": "19.1.0", + "next": "15.4.5" + }, + "devDependencies": { + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@tailwindcss/postcss": "^4", + "tailwindcss": "^4" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/frontend/src/app/apple-touch-icon.png b/frontend/src/app/apple-touch-icon.png new file mode 100644 index 0000000..87a6c37 Binary files /dev/null and b/frontend/src/app/apple-touch-icon.png differ diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000..6a31d75 Binary files /dev/null and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/favicon.svg b/frontend/src/app/favicon.svg new file mode 100644 index 0000000..2050248 --- /dev/null +++ b/frontend/src/app/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..9862b5d --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,391 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + /* Font Families */ + --font-system: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ui-sans-serif, + system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + + /* Primary Brand Colors */ + --color-primary-50: #eff6ff; + --color-primary-100: #dbeafe; + --color-primary-200: #bfdbfe; + --color-primary-300: #93c5fd; + --color-primary-400: #60a5fa; + --color-primary-500: #3b82f6; + --color-primary-600: #2563eb; + --color-primary-700: #1d4ed8; + --color-primary-800: #1e40af; + --color-primary-900: #1e3a8a; + + /* Secondary Colors */ + --color-secondary-50: #f0f9ff; + --color-secondary-100: #e0f2fe; + --color-secondary-200: #bae6fd; + --color-secondary-300: #7dd3fc; + --color-secondary-400: #38bdf8; + --color-secondary-500: #0ea5e9; + --color-secondary-600: #0284c7; + --color-secondary-700: #0369a1; + --color-secondary-800: #075985; + --color-secondary-900: #0c4a6e; + + /* Success Colors */ + --color-success-50: #f0fdf4; + --color-success-100: #dcfce7; + --color-success-200: #bbf7d0; + --color-success-300: #86efac; + --color-success-400: #4ade80; + --color-success-500: #22c55e; + --color-success-600: #16a34a; + --color-success-700: #15803d; + --color-success-800: #166534; + --color-success-900: #14532d; + + /* Danger Colors */ + --color-danger-50: #fef2f2; + --color-danger-100: #fee2e2; + --color-danger-200: #fecaca; + --color-danger-300: #fca5a5; + --color-danger-400: #f87171; + --color-danger-500: #ef4444; + --color-danger-600: #dc2626; + --color-danger-700: #b91c1c; + --color-danger-800: #991b1b; + --color-danger-900: #7f1d1d; + + /* Warning Colors */ + --color-warning-50: #fffbeb; + --color-warning-100: #fef3c7; + --color-warning-200: #fde68a; + --color-warning-300: #fcd34d; + --color-warning-400: #fbbf24; + --color-warning-500: #f59e0b; + --color-warning-600: #d97706; + --color-warning-700: #b45309; + --color-warning-800: #92400e; + --color-warning-900: #78350f; + + /* Custom Box Shadows */ + --shadow-soft: 0 2px 10px rgba(0, 0, 0, 0.1); + --shadow-soft-dark: 0 2px 10px rgba(0, 0, 0, 0.3); + --shadow-elevation: 0 4px 20px rgba(0, 0, 0, 0.15); + --shadow-elevation-dark: 0 4px 20px rgba(0, 0, 0, 0.4); +} + +/* -------------------------------------------------- */ +/* 0. Semantic Color Utilities - Theme System */ +/* -------------------------------------------------- */ + +/* Text Colors - Semantic */ +@utility text-primary { + @apply text-neutral-900 dark:text-neutral-50; +} +@utility text-secondary { + @apply text-neutral-600 dark:text-neutral-400; +} +@utility text-muted { + @apply text-neutral-500 dark:text-neutral-500; +} +@utility text-brand { + @apply text-primary-600 dark:text-primary-400; +} +@utility text-brand-emphasis { + @apply text-primary-700 dark:text-primary-300; +} + +/* Status Text Colors */ +@utility text-status-success { + @apply text-success-700 dark:text-success-400; +} +@utility text-status-danger { + @apply text-danger-700 dark:text-danger-400; +} +@utility text-status-warning { + @apply text-warning-700 dark:text-warning-400; +} +@utility text-status-info { + @apply text-primary-700 dark:text-primary-400; +} + +/* Background Colors - Semantic */ +@utility bg-page { + @apply bg-neutral-50 dark:bg-neutral-900; +} +@utility bg-surface { + @apply bg-white dark:bg-neutral-800; +} +@utility bg-surface-elevated { + @apply bg-white dark:bg-neutral-700; +} +@utility bg-overlay { + @apply bg-black/50 dark:bg-black/70; +} + +/* Status Backgrounds */ +@utility bg-status-success { + @apply bg-success-100 dark:bg-success-900/30; +} +@utility bg-status-danger { + @apply bg-danger-100 dark:bg-danger-900/30; +} +@utility bg-status-warning { + @apply bg-warning-100 dark:bg-warning-900/30; +} +@utility bg-status-info { + @apply bg-primary-100 dark:bg-primary-900/30; +} + +/* Interactive Backgrounds */ +@utility bg-interactive { + @apply bg-neutral-50 dark:bg-neutral-700; +} +@utility bg-interactive-hover { + @apply hover:bg-neutral-100 dark:hover:bg-neutral-600; +} +@utility bg-interactive-active { + @apply bg-neutral-200 dark:bg-neutral-600; +} + +/* Border Colors - Semantic */ +@utility border-default { + @apply border-neutral-200 dark:border-neutral-700; +} +@utility border-subtle { + @apply border-neutral-100 dark:border-neutral-800; +} +@utility border-accent { + @apply border-primary-500 dark:border-primary-400; +} +@utility border-emphasis { + @apply border-primary-600 dark:border-primary-300; +} + +/* Status Borders */ +@utility border-status-success { + @apply border-success-600; +} +@utility border-status-danger { + @apply border-danger-600; +} +@utility border-status-warning { + @apply border-warning-600; +} +@utility border-status-info { + @apply border-primary-600; +} + +/* Focus States */ +@utility focus-accent { + @apply focus:outline-none focus:ring-2 focus:ring-primary-500; +} +@utility focus-danger { + @apply focus:outline-none focus:ring-2 focus:ring-danger-500; +} + +/* Tab States */ +@utility tab-active { + @apply text-brand font-semibold border-b-3 border-accent; +} +@utility tab-inactive { + @apply text-secondary bg-interactive-hover border-b-3 border-transparent hover:border-default; +} + +/* Selection States */ +@utility selected-accent { + @apply border-accent bg-primary-600; +} +@utility unselected-neutral { + @apply border-default bg-surface; +} + +/* -------------------------------------------------- */ +/* 1. Semantic Animation Utilities - Motion System */ +/* -------------------------------------------------- */ + +/* Hover Effects */ +@utility hover-lift { + @apply hover:-translate-y-1 hover:shadow-elevation hover:dark:shadow-elevation-dark; +} +@utility hover-subtle { + @apply hover:bg-interactive-hover; +} +@utility hover-scale { + @apply hover:scale-105; +} + +/* Transition Speeds */ +@utility transition-fast { + transition: all 0.15s ease; +} +@utility transition-smooth { + transition: all 0.2s ease; +} +@utility transition-slow { + transition: all 0.3s ease; +} + +/* Slide Animations */ +@utility slide-in-right { + @apply transform transition-transform duration-500 ease-out translate-x-full; +} +@utility slide-in-right-visible { + @apply translate-x-0; +} +@utility slide-in-left { + @apply transform transition-transform duration-300 ease-out -translate-x-full; +} +@utility slide-in-left-visible { + @apply translate-x-0; +} +@utility slide-in-up { + @apply transform transition-transform duration-300 ease-out translate-y-full; +} +@utility slide-in-up-visible { + @apply translate-y-0; +} + +/* Fade Animations */ +@utility fade-in { + @apply transition-opacity duration-300 ease-out opacity-0; +} +@utility fade-in-visible { + @apply opacity-100; +} +@utility fade-out { + @apply transition-opacity duration-200 ease-in opacity-100; +} +@utility fade-out-hidden { + @apply opacity-0; +} + +/* Interactive States */ +@utility interactive-element { + @apply transition-smooth cursor-pointer focus-accent; +} +@utility card-interactive { + @apply interactive-element hover-lift; +} + +/* Focus Animations */ +@utility focus-ring { + @apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all duration-200; +} +@utility focus-ring-danger { + @apply focus:outline-none focus:ring-2 focus:ring-danger-500 focus:ring-offset-2 transition-all duration-200; +} + +/* -------------------------------------------------- */ +/* 2. Base layer: resets, defaults, theming, scrollbar */ +/* -------------------------------------------------- */ +@layer base { + html { + font-family: var(--font-system); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scroll-behavior: smooth; + } + + * { + @apply transition duration-200 ease-in-out; + } + .transition-disabled * { + @apply transition-none; + } + + body { + @apply bg-page text-primary min-h-screen flex flex-col; + } + + /* touch feedback */ + button, + a { + -webkit-tap-highlight-color: transparent; + cursor: pointer; + } + + select { + @apply appearance-none bg-surface border border-default cursor-pointer focus-accent px-3 py-2 rounded-lg w-full; + } + input { + @apply bg-surface border border-default focus-accent px-3 py-2 rounded-lg w-full; + } + + /* Scrollbars */ + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + *::-webkit-scrollbar-track { + background: transparent; + } + *::-webkit-scrollbar-thumb { + @apply bg-neutral-300 dark:bg-neutral-700 rounded-lg; + } +} + +/* -------------------------------------------------- */ +/* 3. Components layer: reusable patterns */ +/* -------------------------------------------------- */ + +@utility btn { + @apply inline-flex items-center justify-center font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 cursor-pointer px-4 py-2 disabled:opacity-50 disabled:bg-neutral-500 disabled:hover:bg-neutral-600 text-white; +} + +@layer components { + /* Buttons */ + .btn-primary { + @apply btn bg-primary-500 hover:bg-primary-600 focus:ring-primary-300; + } + .btn-secondary { + @apply btn bg-secondary-500 hover:bg-secondary-600 focus:ring-secondary-300; + } + .btn-success { + @apply btn bg-success-500 hover:bg-success-600 focus:ring-success-300; + } + .btn-danger { + @apply btn bg-danger-500 hover:bg-danger-600 focus:ring-danger-300; + } + .btn-warning { + @apply btn bg-warning-500 hover:bg-warning-600 focus:ring-warning-300; + } + + /* Cards */ + .card { + @apply bg-surface border border-default rounded-xl shadow-soft dark:shadow-soft-dark p-4 border-1 w-full; + } + + /* Custom styled select with arrow */ + .icon-button { + @apply inline-flex items-center justify-center w-9 h-9 text-sm rounded-lg bg-surface bg-interactive-hover border border-default focus-accent; + } + + .voucher-code { + @apply font-bold text-brand font-mono tracking-wider; + } +} + +/* -------------------------------------------------- */ +/* 4. Utilities layer: small helpers */ +/* -------------------------------------------------- */ +@layer utilities { + /* Flex center shortcuts */ + .flex-center { + @apply flex items-center justify-center; + } + + /* Safe area padding for iOS */ + .safe-top { + padding-top: env(safe-area-inset-top); + } + .safe-bottom { + padding-bottom: env(safe-area-inset-bottom); + } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..5fd8849 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,24 @@ +import "./globals.css"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "UniFi Voucher Manager", + description: "Manage WiFi vouchers with ease", + authors: [{ name: "etiennecollin", url: "https://etiennecollin.com" }], + creator: "Etienne Collin", + robots: { + index: false, + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..8685dd4 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,13 @@ +import Header from "@/components/Header"; +import NotificationContainer from "@/components/notifications/NotificationContainer"; +import Tabs from "@/components/tabs/Tabs"; + +export default function Home() { + return ( +
+ +
+ +
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..7798c4c --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,16 @@ +import ThemeSwitcher from "@/components/utils/ThemeSwitcher"; + +export default function Header() { + return ( +
+
+

+ UniFi Voucher Manager +

+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/VoucherCard.tsx b/frontend/src/components/VoucherCard.tsx new file mode 100644 index 0000000..300969b --- /dev/null +++ b/frontend/src/components/VoucherCard.tsx @@ -0,0 +1,86 @@ +import { Voucher } from "@/types/voucher"; +import { + formatCode, + formatDate, + formatDuration, + formatGuestUsage, +} from "@/utils/format"; +import { memo } from "react"; + +type Props = { + voucher: Voucher; + selected: boolean; + editMode: boolean; + onClick: () => void; +}; + +const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => { + const statusClass = voucher.expired + ? "bg-status-danger text-status-danger" + : "bg-status-success text-status-success"; + + return ( +
+ {editMode && ( +
+
+ {selected &&
} +
+
+ )} + + {/* Primary Information */} +
+
{formatCode(voucher.code)}
+
{voucher.name}
+
+ +
+
+ Guests Used: + + {formatGuestUsage( + voucher.authorizedGuestCount, + voucher.authorizedGuestLimit, + )} + +
+ +
+ Session Time: + {formatDuration(voucher.timeLimitMinutes)} +
+ + {voucher.activatedAt && ( +
+ First Used: + {formatDate(voucher.activatedAt)} +
+ )} + +
+ + {voucher.expired ? "Expired" : "Active"} + + {voucher.expiresAt && ( + + Expires: {formatDate(voucher.expiresAt)} + + )} +
+
+
+ ); +}; + +export default memo(VoucherCard); diff --git a/frontend/src/components/modals/Modal.tsx b/frontend/src/components/modals/Modal.tsx new file mode 100644 index 0000000..44330c1 --- /dev/null +++ b/frontend/src/components/modals/Modal.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { ReactNode, useEffect } from "react"; + +type Props = { + onClose: () => void; + /** Extra classes for the content container */ + contentClassName?: string; + children: ReactNode; +}; + +export default function Modal({ + onClose, + contentClassName = "", + children, +}: Props) { + // lock scroll + handle Escape + useEffect(() => { + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + + return () => { + document.body.style.overflow = prevOverflow; + window.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + const onBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + return ( +
+
+ +
{children}
+
+
+ ); +} diff --git a/frontend/src/components/modals/SuccessModal.tsx b/frontend/src/components/modals/SuccessModal.tsx new file mode 100644 index 0000000..58e430d --- /dev/null +++ b/frontend/src/components/modals/SuccessModal.tsx @@ -0,0 +1,20 @@ +"use client"; + +import Modal from "@/components/modals/Modal"; +import CopyCode from "@/components/utils/CopyCode"; + +type Props = { + code: string; + onClose: () => void; +}; + +export default function SuccessModal({ code: rawCode, onClose }: Props) { + return ( + +

+ Voucher Created! +

+ +
+ ); +} diff --git a/frontend/src/components/modals/VoucherModal.tsx b/frontend/src/components/modals/VoucherModal.tsx new file mode 100644 index 0000000..e6f9d12 --- /dev/null +++ b/frontend/src/components/modals/VoucherModal.tsx @@ -0,0 +1,105 @@ +"use client"; + +import Modal from "@/components/modals/Modal"; +import Spinner from "@/components/utils/Spinner"; +import { api } from "@/utils/api"; +import { useEffect, useRef, useState } from "react"; +import { + formatBytes, + formatDate, + formatDuration, + formatGuestUsage, + formatSpeed, +} from "@/utils/format"; +import CopyCode from "@/components/utils/CopyCode"; +import { Voucher } from "@/types/voucher"; + +type Props = { + voucher: Voucher; + onClose: () => void; +}; + +export default function VoucherModal({ voucher, onClose }: Props) { + const [details, setDetails] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const lastFetchedId = useRef(null); + + useEffect(() => { + // Only fetch if we haven't already fetched this voucher's details + if (voucher.id === lastFetchedId.current) { + return; + } + + (async () => { + setLoading(true); + setError(false); + lastFetchedId.current = voucher.id; + + try { + const res = await api.getVoucherDetails(voucher.id); + setDetails(res); + } catch { + setError(true); + } finally { + setLoading(false); + } + })(); + }, [voucher.id]); + + const rawCode = details?.code ?? voucher.code; + + return ( + + + {loading ? ( + + ) : error || details == null ? ( +
+ Failed to load detailed information +
+ ) : ( +
+ {( + [ + ["Status", details.expired ? "Expired" : "Active"], + ["Name", details.name || "No note"], + ["Created", formatDate(details.createdAt)], + ...(details.activatedAt + ? [["Activated", formatDate(details.activatedAt)]] + : []), + ...(details.expiresAt + ? [["Expires", formatDate(details.expiresAt)]] + : []), + ["Duration", formatDuration(details.timeLimitMinutes)], + [ + "Guest Usage", + formatGuestUsage( + details.authorizedGuestCount, + details.authorizedGuestLimit, + ), + ], + [ + "Data Limit", + details.dataUsageLimitMBytes + ? formatBytes(details.dataUsageLimitMBytes * 1024 * 1024) + : "Unlimited", + ], + ["Download Speed", formatSpeed(details.rxRateLimitKbps)], + ["Upload Speed", formatSpeed(details.txRateLimitKbps)], + ["ID", details.id], + ] as [string, any][] + ).map(([label, value]) => ( +
+ {label}: + {value} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/notifications/NotificationContainer.tsx b/frontend/src/components/notifications/NotificationContainer.tsx new file mode 100644 index 0000000..0fff9d2 --- /dev/null +++ b/frontend/src/components/notifications/NotificationContainer.tsx @@ -0,0 +1,30 @@ +"use client"; + +import NotificationItem from "@/components/notifications/NotificationItem"; +import { NotificationPayload } from "@/utils/notifications"; +import { useEffect, useState, useCallback } from "react"; + +export default function NotificationContainer() { + const [notes, setNotes] = useState([]); + + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + setNotes((prev) => [...prev, detail]); + }; + window.addEventListener("notify", handler as any); + return () => window.removeEventListener("notify", handler as any); + }, []); + + const remove = useCallback((id: string) => { + setNotes((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return ( +
+ {notes.map((n) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/notifications/NotificationItem.tsx b/frontend/src/components/notifications/NotificationItem.tsx new file mode 100644 index 0000000..699fe0c --- /dev/null +++ b/frontend/src/components/notifications/NotificationItem.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { NotificationPayload } from "@/utils/notifications"; +import { useEffect, useState } from "react"; + +type Props = NotificationPayload & { + onDone: (id: string) => void; +}; + +const NOTIFICATION_DURATION_MS = 4000; + +export default function NotificationItem({ id, message, type, onDone }: Props) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + // slide in next tick + const showTimer = setTimeout(() => setVisible(true), 10); + // slide out after 4s + const hideTimer = setTimeout( + () => setVisible(false), + NOTIFICATION_DURATION_MS, + ); + // remove after animation + const removeTimer = setTimeout( + () => onDone(id), + NOTIFICATION_DURATION_MS + 500, + ); + + return () => [showTimer, hideTimer, removeTimer].forEach(clearTimeout); + }, [id, onDone]); + + const getNotificationClasses = () => { + switch (type) { + case "success": + return "border-status-success text-status-success"; + case "error": + return "border-status-danger text-status-danger"; + default: + return "border-status-info text-status-info"; + } + }; + + return ( +
+ {message} +
+ ); +} diff --git a/frontend/src/components/tabs/CustomCreateTab.tsx b/frontend/src/components/tabs/CustomCreateTab.tsx new file mode 100644 index 0000000..2fb3efb --- /dev/null +++ b/frontend/src/components/tabs/CustomCreateTab.tsx @@ -0,0 +1,112 @@ +"use client"; + +import SuccessModal from "@/components/modals/SuccessModal"; +import { VoucherCreateData } from "@/types/voucher"; +import { api } from "@/utils/api"; +import { map } from "@/utils/functional"; +import { notify } from "@/utils/notifications"; +import { useCallback, useState, FormEvent } from "react"; + +export default function CustomCreateTab() { + const [loading, setLoading] = useState(false); + const [newCode, setNewCode] = useState(null); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setLoading(true); + + const parseNumber = (x: FormDataEntryValue) => + x !== "" ? Number(x) : null; + + const form = e.currentTarget; + const data = new FormData(form); + + const payload: VoucherCreateData = { + count: Number(data.get("count")), + name: String(data.get("name")), + timeLimitMinutes: Number(data.get("duration")), + authorizedGuestLimit: map(data.get("guests"), parseNumber), + dataUsageLimitMBytes: map(data.get("data"), parseNumber), + rxRateLimitKbps: map(data.get("download"), parseNumber), + txRateLimitKbps: map(data.get("upload"), parseNumber), + }; + + try { + const res = await api.createVoucher(payload); + const code = res.vouchers?.[0]?.code; + if (code) { + setNewCode(code); + form.reset(); + } else { + notify("Voucher created, but code not available", "error"); + } + } catch { + notify("Failed to create voucher", "error"); + } + setLoading(false); + }; + + const closeModal = useCallback(() => { + setNewCode(null); + }, []); + + return ( +
+
+ {[ + { + label: "Number", + name: "count", + type: "number", + props: { required: true, min: 1, max: 10, defaultValue: 1 }, + }, + { + label: "Name", + name: "name", + type: "text", + props: { required: true, defaultValue: "Custom Voucher" }, + }, + { + label: "Duration (min)", + name: "duration", + type: "number", + props: { required: true, min: 1, max: 525600, defaultValue: 1440 }, + }, + { + label: "Guest Limit", + name: "guests", + type: "number", + props: { min: 1, max: 5, placeholder: "Unlimited" }, + }, + { + label: "Data Limit (MB)", + name: "data", + type: "number", + props: { min: 1, max: 1048576, placeholder: "Unlimited" }, + }, + { + label: "Download Kbps", + name: "download", + type: "number", + props: { min: 2, max: 100000, placeholder: "Unlimited" }, + }, + { + label: "Upload Kbps", + name: "upload", + type: "number", + props: { min: 2, max: 100000, placeholder: "Unlimited" }, + }, + ].map(({ label, name, type, props }) => ( +
+ + +
+ ))} + +
+ {newCode && } +
+ ); +} diff --git a/frontend/src/components/tabs/QuickCreateTab.tsx b/frontend/src/components/tabs/QuickCreateTab.tsx new file mode 100644 index 0000000..34a8bc8 --- /dev/null +++ b/frontend/src/components/tabs/QuickCreateTab.tsx @@ -0,0 +1,69 @@ +"use client"; + +import SuccessModal from "@/components/modals/SuccessModal"; +import { VoucherCreateData } from "@/types/voucher"; +import { api } from "@/utils/api"; +import { notify } from "@/utils/notifications"; +import { useCallback, useState, FormEvent } from "react"; + +export default function QuickCreateTab() { + const [loading, setLoading] = useState(false); + const [newCode, setNewCode] = useState(null); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setLoading(true); + + const form = e.currentTarget; + const data = new FormData(form); + + const payload: VoucherCreateData = { + count: 1, + name: String(data.get("name")), + timeLimitMinutes: Number(data.get("duration")), + authorizedGuestLimit: 1, + }; + + try { + const res = await api.createVoucher(payload); + const code = res.vouchers?.[0]?.code; + if (code) { + setNewCode(code); + form.reset(); + } else { + notify("Voucher created, but code not available", "error"); + } + } catch { + notify("Failed to create voucher", "error"); + } + setLoading(false); + }; + + const closeModal = useCallback(() => { + setNewCode(null); + }, []); + + return ( +
+
+ + + + + + + +
+ + {newCode && } +
+ ); +} diff --git a/frontend/src/components/tabs/Tabs.tsx b/frontend/src/components/tabs/Tabs.tsx new file mode 100644 index 0000000..5cc6c5c --- /dev/null +++ b/frontend/src/components/tabs/Tabs.tsx @@ -0,0 +1,74 @@ +"use client"; + +import CustomCreateTab from "@/components/tabs/CustomCreateTab"; +import TestTab from "@/components/tabs/TestTab"; +import QuickCreateTab from "@/components/tabs/QuickCreateTab"; +import VouchersTab from "@/components/tabs/VouchersTab"; +import { useState } from "react"; + +const TAB_CONFIG = [ + { + id: "vouchers", + label: "View Vouchers", + component: VouchersTab, + enabled: true, + }, + { + id: "quick", + label: "Quick Create", + component: QuickCreateTab, + enabled: true, + }, + { + id: "custom", + label: "Custom Create", + component: CustomCreateTab, + enabled: true, + }, + { + id: "test", + label: "Test", + component: TestTab, + enabled: false, + }, +] as const; + +// Get enabled tabs and derive types +const enabledTabs = TAB_CONFIG.filter((tab) => tab.enabled); +const tabIds = enabledTabs.map((tab) => tab.id); +type TabId = (typeof tabIds)[number]; + +export default function Tabs() { + const [tab, setTab] = useState(tabIds[0]); + + return ( + <> + +
+ {enabledTabs.map((tabConfig) => { + const Component = tabConfig.component; + return ( +
+ +
+ ); + })} +
+ + ); +} diff --git a/frontend/src/components/tabs/TestTab.tsx b/frontend/src/components/tabs/TestTab.tsx new file mode 100644 index 0000000..a65d5db --- /dev/null +++ b/frontend/src/components/tabs/TestTab.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { notify } from "@/utils/notifications"; +import Spinner from "@/components/utils/Spinner"; + +export default function TestTab() { + const sendInfo = () => notify("This is an info notification", "info"); + const sendSuccess = () => notify("Operation succeeded!", "success"); + const sendError = () => notify("Something went wrong!", "error"); + const sendMultiple = () => { + notify("First message", "info"); + setTimeout(() => notify("Second message", "success"), 500); + setTimeout(() => notify("Third message", "error"), 1000); + }; + + return ( +
+
+

+ Notification Tester +

+
+ + + + +
+
+
+

Spinner Example

+ +
+
+ ); +} diff --git a/frontend/src/components/tabs/VouchersTab.tsx b/frontend/src/components/tabs/VouchersTab.tsx new file mode 100644 index 0000000..0d80292 --- /dev/null +++ b/frontend/src/components/tabs/VouchersTab.tsx @@ -0,0 +1,216 @@ +"use client"; + +import Spinner from "@/components/utils/Spinner"; +import VoucherCard from "@/components/VoucherCard"; +import VoucherModal from "@/components/modals/VoucherModal"; +import { Voucher } from "@/types/voucher"; +import { api } from "@/utils/api"; +import { notify } from "@/utils/notifications"; +import { useMemo, useEffect, useCallback, useState } from "react"; + +export default function VouchersTab() { + const [loading, setLoading] = useState(true); + const [vouchers, setVouchers] = useState([]); + const [viewVoucher, setViewVoucher] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [editMode, setEditMode] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await api.getAllVouchers(); + setVouchers(res.data || []); + } catch { + notify("Failed to load vouchers", "error"); + } + setLoading(false); + }, []); + + const startEdit = () => { + setSelected(new Set()); + setEditMode(true); + }; + + const cancelEdit = useCallback(() => { + setSelected(new Set()); + setEditMode(false); + }, []); + + useEffect(() => { + load(); + window.addEventListener("vouchersUpdated", load); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") cancelEdit(); + }; + window.addEventListener("keydown", onKey); + + return () => { + window.removeEventListener("vouchersUpdated", load); + window.removeEventListener("keydown", onKey); + }; + }, [load, cancelEdit]); + + const filteredVouchers = useMemo(() => { + if (!searchQuery.trim()) return vouchers; + + const query = searchQuery.toLowerCase().trim(); + return vouchers.filter((voucher) => + voucher.name?.toLowerCase().includes(query), + ); + }, [vouchers, searchQuery]); + + const expiredVouchers = useMemo( + () => filteredVouchers.filter((v) => v.expired).map((v) => v.id), + [filteredVouchers], + ); + + const toggleSelect = useCallback((id: string) => { + setSelected((p) => { + const s = new Set(p); + s.has(id) ? s.delete(id) : s.add(id); + return s; + }); + }, []); + + const selectAll = () => { + if (selected.size === filteredVouchers.length) { + setSelected(new Set()); + } else { + setSelected(new Set(filteredVouchers.map((v) => v.id))); + } + }; + + const closeModal = useCallback(() => { + setViewVoucher(null); + }, []); + + const deleteVouchers = useCallback( + async (kind: "selected" | "expired") => { + setBusy(true); + const kind_word = kind === "selected" ? "" : "expired"; + try { + const res = + kind === "selected" + ? await api.deleteSelected([...selected]) + : await api.deleteSelected([...expiredVouchers]); + const count = res.vouchersDeleted || 0; + if (count > 0) { + notify( + `Successfully deleted ${count} ${kind_word} voucher${count === 1 ? "" : "s"}`, + "success", + ); + setSelected(new Set()); + } else { + notify(`No ${kind_word} vouchers were deleted`, "info"); + } + } catch { + notify(`Failed to delete ${kind_word} vouchers`, "error"); + } + setBusy(false); + }, + [selected], + ); + + return ( +
+
+
+ setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+
+
+ {!editMode ? ( + <> + + + + ) : ( + <> + + + + + {busy ? : <>} + + {selected.size} selected + + + )} +
+ + {searchQuery && ( +
+ Showing {filteredVouchers.length} of {vouchers.length} vouchers +
+ )} + + {loading ? ( + + ) : !filteredVouchers.length ? ( +
+ {searchQuery + ? "No vouchers found matching your search" + : "No vouchers found"} +
+ ) : ( +
+ {filteredVouchers.map((v) => ( + + editMode ? toggleSelect(v.id) : setViewVoucher(v) + } + /> + ))} +
+ )} + + {viewVoucher && ( + + )} +
+ ); +} diff --git a/frontend/src/components/utils/CopyCode.tsx b/frontend/src/components/utils/CopyCode.tsx new file mode 100644 index 0000000..8434f17 --- /dev/null +++ b/frontend/src/components/utils/CopyCode.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { copyText } from "@/utils/clipboard"; +import { formatCode } from "@/utils/format"; +import { notify } from "@/utils/notifications"; +import { useState } from "react"; + +type Props = { + rawCode: string; + contentClassName?: string; +}; + +export default function CopyCode({ rawCode, contentClassName = "" }: Props) { + const code = formatCode(rawCode); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (await copyText(rawCode)) { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + notify("Code copied to clipboard!", "success"); + } else { + notify("Failed to copy code", "error"); + } + }; + + return ( +
+
+ {code} +
+ + +
+ ); +} diff --git a/frontend/src/components/utils/Spinner.tsx b/frontend/src/components/utils/Spinner.tsx new file mode 100644 index 0000000..6ea31d7 --- /dev/null +++ b/frontend/src/components/utils/Spinner.tsx @@ -0,0 +1,10 @@ +export default function Spinner() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/utils/ThemeSwitcher.tsx b/frontend/src/components/utils/ThemeSwitcher.tsx new file mode 100644 index 0000000..2b1a77c --- /dev/null +++ b/frontend/src/components/utils/ThemeSwitcher.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { notify } from "@/utils/notifications"; + +export default function ThemeSwitcher() { + type themeType = "system" | "light" | "dark"; + const [theme, setTheme] = useState("system"); + + // Load saved theme + useEffect(() => { + const stored = localStorage.getItem("theme") as themeType | null; + setTheme(stored || "system"); + }, []); + + // Apply theme class + useEffect(() => { + const html = document.documentElement; + const mql = window.matchMedia("(prefers-color-scheme: dark)"); + const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + + const apply = () => { + // Only disable transitions on Safari + if (isSafari) { + html.classList.add("transition-disabled"); + notify("Theme changed, reloading for Safari compatibility", "info"); + } + + const isDark = theme === "dark" || (theme === "system" && mql.matches); + html.classList.toggle("dark", isDark); + localStorage.setItem("theme", theme); + + // Re-enable transitions after a brief delay (Safari only) + if (isSafari) { + requestAnimationFrame(() => { + setTimeout(() => { + html.classList.remove("transition-disabled"); + }, 50); + }); + } + }; + + apply(); + + // For system mode, listen to changes + mql.addEventListener("change", apply); + + return () => { + mql.removeEventListener("change", apply); + }; + }, [theme]); + + return ( + + ); +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts new file mode 100644 index 0000000..627035d --- /dev/null +++ b/frontend/src/middleware.ts @@ -0,0 +1,17 @@ +import { NextResponse, NextRequest } from "next/server"; + +export const config = { + matcher: "/api/:path*", +}; + +const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1"; +const DEFAULT_BACKEND_BIND_PORT = "8080"; + +export function middleware(request: NextRequest) { + return NextResponse.rewrite( + new URL( + `${process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL}:${process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT}${request.nextUrl.pathname}${request.nextUrl.search}`, + ), + { request }, + ); +} diff --git a/frontend/src/types/voucher.ts b/frontend/src/types/voucher.ts new file mode 100644 index 0000000..4664d93 --- /dev/null +++ b/frontend/src/types/voucher.ts @@ -0,0 +1,37 @@ +export interface Voucher { + id: string; + createdAt: string; + name: string; + code: string; + authorizedGuestLimit?: number | null; + authorizedGuestCount: number; + activatedAt?: string | null; + expiresAt?: string | null; + expired: boolean; + timeLimitMinutes: number; + dataUsageLimitMBytes?: number | null; + rxRateLimitKbps?: number | null; + txRateLimitKbps?: number | null; +} + +export interface VoucherCreateData + extends Omit< + Voucher, + | "id" + | "createdAt" + | "code" + | "authorizedGuestCount" + | "activatedAt" + | "expiresAt" + | "expired" + > { + count: number; +} + +export interface VoucherDeletedResponse { + vouchersDeleted: number; +} + +export interface VoucherCreatedResponse { + vouchers: Voucher[]; +} diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts new file mode 100644 index 0000000..b2aa6af --- /dev/null +++ b/frontend/src/utils/api.ts @@ -0,0 +1,64 @@ +import { + Voucher, + VoucherCreateData, + VoucherCreatedResponse, + VoucherDeletedResponse, +} from "@/types/voucher"; + +function removeNullUndefined>(obj: T): T { + return Object.fromEntries( + Object.entries(obj).filter( + ([_, value]) => value !== null && value !== undefined, + ), + ) as T; +} + +async function call(endpoint: string, opts: RequestInit = {}) { + const res = await fetch(`/api/${endpoint}`, { + headers: { "Content-Type": "application/json" }, + ...opts, + }); + if (!res.ok) throw new Error(res.statusText); + return res.json() as Promise; +} + +function notifyVouchersUpdated() { + window.dispatchEvent(new CustomEvent("vouchersUpdated")); +} + +export const api = { + getAllVouchers: () => call<{ data: Voucher[] }>("/vouchers"), + + getVoucherDetails: (id: string) => + call(`/vouchers/details?id=${encodeURIComponent(id)}`), + + createVoucher: async (data: VoucherCreateData) => { + const filteredData = removeNullUndefined(data); + const result = await call("/vouchers", { + method: "POST", + body: JSON.stringify(filteredData), + }); + notifyVouchersUpdated(); + return result; + }, + + deleteExpired: async () => { + const result = await call("/vouchers/expired", { + method: "DELETE", + }); + notifyVouchersUpdated(); + return result; + }, + + deleteSelected: async (ids: string[]) => { + const qs = ids.map(encodeURIComponent).join(","); + const result = await call( + `/vouchers/selected?ids=${qs}`, + { + method: "DELETE", + }, + ); + notifyVouchersUpdated(); + return result; + }, +}; diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts new file mode 100644 index 0000000..02ba96b --- /dev/null +++ b/frontend/src/utils/clipboard.ts @@ -0,0 +1,34 @@ +/** + * Copy the given text to clipboard. + * Tries the modern Clipboard API first; on failure, falls back to execCommand. + * Returns true if the text was successfully copied. + */ +export async function copyText(text: string): Promise { + // Try Clipboard API + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // fall through to fallback + } + } + + // Fallback to textarea + execCommand + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + + let success = false; + try { + success = document.execCommand("copy"); + } catch { + success = false; + } + document.body.removeChild(textarea); + return success; +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..79e3392 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,49 @@ +export function formatCode(code: string) { + return code.length === 10 ? code.replace(/(.{5})(.{5})/, "$1-$2") : code; +} + +export function formatDate(d: string) { + return new Date(d).toLocaleString(); +} + +export function formatDuration(m: number | null | undefined) { + if (!m) return "Unlimited"; + const days = Math.floor(m / 1440), + hours = Math.floor((m % 1440) / 60), + mins = m % 60; + return ( + [ + days > 0 ? days + "d" : "", + hours > 0 ? hours + "h" : "", + mins > 0 ? mins + "m" : "", + ] + .filter(Boolean) + .join(" ") || "0m" + ); +} + +export function formatBytes(b: number | null | undefined) { + if (!b) return "Unlimited"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = b, + i = 0; + while (size >= 1024 && i < units.length - 1) { + size /= 1024; + i++; + } + return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`; +} + +export function formatSpeed(kbps: number | null | undefined) { + if (!kbps) return "Unlimited"; + return kbps >= 1024 + ? `${(kbps / 1024).toFixed(kbps < 10240 ? 1 : 0)} Mbps` + : `${kbps} Kbps`; +} + +export function formatGuestUsage( + usage: number, + limit: number | null | undefined, +) { + return limit ? `${usage}/${limit}` : `${usage}/∞`; +} diff --git a/frontend/src/utils/functional.ts b/frontend/src/utils/functional.ts new file mode 100644 index 0000000..6a913aa --- /dev/null +++ b/frontend/src/utils/functional.ts @@ -0,0 +1,6 @@ +export function map(value: T | null, fn: (v: T) => U): U | null { + /** + * Returns the result of applying fn to value if value is non-null, otherwise returns null. + */ + return value != null ? fn(value) : null; +} diff --git a/frontend/src/utils/notifications.ts b/frontend/src/utils/notifications.ts new file mode 100644 index 0000000..9d6d110 --- /dev/null +++ b/frontend/src/utils/notifications.ts @@ -0,0 +1,43 @@ +export type NotificationType = "success" | "error" | "info"; + +export interface NotificationPayload { + id: string; + message: string; + type: NotificationType; +} + +/** Generate a RFC-4122 v4 UUID */ +function generateUUID(): string { + // use crypto.randomUUID() when available + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + // @ts-ignore + return crypto.randomUUID(); + } + + // fallback to crypto.getRandomValues + let d = new Date().getTime(); + let d2 = (performance && performance.now && performance.now() * 1000) || 0; + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = + Math.random() * 16 + + // use high-res entropy if available + (crypto && crypto.getRandomValues + ? crypto.getRandomValues(new Uint8Array(1))[0] + : 0); + const v = c === "x" ? r % 16 | 0 : (r % 16 & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * Dispatch a notification event. Listeners (e.g. NotificationContainer) + * will pick this up and render it. + */ +export function notify(message: string, type: NotificationType = "info") { + const id = generateUUID(); + window.dispatchEvent( + new CustomEvent("notify", { + detail: { id, message, type }, + }), + ); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..c133409 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100644 index 0000000..5b173eb --- /dev/null +++ b/scripts/healthcheck.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -e + +# Check backend health +echo "Checking backend on port $BACKEND_BIND_PORT..." +wget --no-verbose --tries=1 --spider --timeout=5 "http://localhost:$BACKEND_BIND_PORT/api/health" 2>/dev/null || { + echo "Backend health check failed on port $BACKEND_BIND_PORT" + exit 1 +} + +# Check frontend health +echo "Checking frontend on port $FRONTEND_BIND_PORT..." +wget --no-verbose --tries=1 --spider --timeout=5 "http://localhost:$FRONTEND_BIND_PORT" 2>/dev/null || { + echo "Frontend health check failed on port $FRONTEND_BIND_PORT" + exit 1 +} + +echo "Both services are healthy" +exit 0 diff --git a/scripts/run_wrapper.sh b/scripts/run_wrapper.sh new file mode 100644 index 0000000..652f830 --- /dev/null +++ b/scripts/run_wrapper.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env sh + +echo "================================================================" +echo "Starting services..." +echo "Frontend will listen on: ${FRONTEND_BIND_HOST}:${FRONTEND_BIND_PORT}" +echo "Backend will listen on: ${BACKEND_BIND_HOST}:${BACKEND_BIND_PORT}" +echo "================================================================" + +# Start backend in background +echo "Starting backend..." +./backend & +BACKEND_PID=$! + +# Wait for backend to initialize +sleep 3 + +# Start frontend in foreground +echo "Starting frontend..." +NEXT_TELEMETRY_DISABLED="1" NODE_ENV="production" HOSTNAME=${FRONTEND_BIND_HOST} PORT="${FRONTEND_BIND_PORT}" node ./frontend/server.js & +FRONTEND_PID=$! + +cleanup() { + echo "================================================================" + echo "Shutting down services..." + kill $BACKEND_PID $FRONTEND_PID 2>/dev/null + wait $BACKEND_PID $FRONTEND_PID 2>/dev/null + echo "Frontend and Backend services have been shut down." + echo "================================================================" + exit 0 +} + +# Set up signal handlers +trap cleanup SIGTERM SIGINT + +# Wait for any process to exit +wait $BACKEND_PID $FRONTEND_PID + +# Exit with status of process that exited first +exit $?