Initial release

This commit is contained in:
2025-11-23 20:42:56 +01:00
commit 721217a8e3
130 changed files with 7099 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
FROM imbios/bun-node:1.3.2-iron-slim AS base
RUN apt update && \
apt install -y openssl
USER node
+24
View File
@@ -0,0 +1,24 @@
{
"name": "URL Shortener Backend",
"dockerFile": "Dockerfile",
"remoteUser": "node",
"workspaceFolder": "/workspaces",
"workspaceMount": "source=${localWorkspaceFolder}/backend,target=/workspaces,type=bind",
"forwardPorts": [
3000
],
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.defaultProfile.linux": "bash",
"biome.enable": true
},
"extensions": [
"biomejs.biome",
"Prisma.prisma",
"humao.rest-client"
]
}
},
"postCreateCommand": "bun install"
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "URL Shortener Frontend",
"image": "oven/bun:1",
"remoteUser": "bun",
"workspaceFolder": "/workspaces",
"workspaceMount": "source=${localWorkspaceFolder}/frontend,target=/workspaces,type=bind",
"forwardPorts": [
5173
],
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.defaultProfile.linux": "bash",
"terminal.integrated.profiles.linux": {
"bash": {
"path": "/bin/bash"
}
},
"extensions": []
},
"extensions": [
"burkeholland.simple-react-snippets",
"dsznajder.es7-react-js-snippets",
"biomejs.biome",
"bradlc.vscode-tailwindcss"
]
}
},
"postCreateCommand": "bun install"
}
+22
View File
@@ -0,0 +1,22 @@
# Compose environment variables
DOMAIN=localhost
STACK_NAME=url-shortener
# Feature toggles
SIGNUP_ENABLED=true
# Frontend environment variables
VITE_DOMAIN=http://${DOMAIN}
VITE_API_BASE_URL=http://${DOMAIN}/api
VITE_SIGNUP_ENABLED=${SIGNUP_ENABLED}
# Backend environment variables
PORT=3000
DATABASE_URL=file:/app/prisma/local.db
JWT_SECRET=your_jwt_secret # openssl rand -base64 32
CORS_ORIGIN=http://short.${DOMAIN}
# Prisma studio
PRISMA_USERNAME=admin
# bcrypt hash for "adminpassword" (echo $(htpasswd -nB user) | sed -e s/\\$/\\$\\$/g)
PRISMA_PASSWORD=$$2y$$05$$YW.WZVlOrHM70Ra2JeuvJOTJeEVQcCZhrSatbOAATei2h1OVGZn3i
+51
View File
@@ -0,0 +1,51 @@
# Dependencies
node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Output
out
dist
dist-ssr
*.tgz
# Environment variables
.env
.env.development
.env.test
.env.production
.env.local
.env.*.local
*.local
!.env.example
# Caches and Coverage
coverage
*.lcov
.eslintcache
.cache
*.tsbuildinfo
# IDEs and Editors
.idea
!.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS
.DS_Store
# Backend Specific
*.db
backend/src/generated/prisma
+64
View File
@@ -0,0 +1,64 @@
# URL Shortener
A modern, full-stack URL shortener application built with a separate backend and frontend, orchestrated via Docker Compose.
## Prerequisites
Before you begin, ensure you have the following installed on your machine:
- [Docker](https://docs.docker.com/get-docker/)
- [Docker Compose](https://docs.docker.com/compose/install/)
## Configuration
1. **Clone the repository:**
```bash
git clone <repository-url>
cd url-shortener
```
2. **Environment Setup:**
Copy the example environment file to create your local configuration:
```bash
cp .env.example .env
```
3. **Configure Environment Variables:**
Open the `.env` file and adjust the variables as needed. Key variables include:
- `DOMAIN`: The base domain for the application (default: `localhost`).
- `STACK_NAME`: The name of the Docker stack (default: `url-shortener`).
- `JWT_SECRET`: A secret key for signing JWTs. **Change this to a secure random string.**
- `PRISMA_USERNAME` & `PRISMA_PASSWORD`: Credentials for accessing Prisma Studio.
- Note: `PRISMA_PASSWORD` must be a bcrypt hash. The example file contains the hash for "adminpassword".
## Deployment
To start the application in a local development environment, run:
```bash
docker compose -f compose.yaml -f compose.local.yaml up -d --build
```
This command builds the images and starts the services in detached mode.
### Accessing the Services
Once the containers are up and running, you can access the various components at the following URLs (assuming default `DOMAIN=localhost`):
- **Frontend (URL Shortener UI):** [http://short.localhost](http://short.localhost)
- **Backend API:** [http://localhost/api](http://localhost/api)
- **Prisma Studio (Database GUI):** [http://studio.localhost](http://studio.localhost)
- *Auth required (default: admin / adminpassword)*
- **Traefik Dashboard:** [http://traefik.localhost](http://traefik.localhost)
## Project Structure
The project is organized into two main directories, each with its own README for more detailed information:
- **`backend/`**: Contains the Bun API and database logic.
- **`frontend/`**: Contains the React/Vite frontend application.
+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
.devcontainer
.vscode
.dockerignore
node_modules
dist
Dockerfile*
compose*
README.md
*.http
+19
View File
@@ -0,0 +1,19 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Development",
"type": "dockerfile",
"request": "launch",
"dockerfile": "Dockerfile",
"contextPath": "${workspaceFolder}",
"args": [
{
"name": "NODE_ENV",
"value": "development"
}
]
}
],
"compounds": []
}
+18
View File
@@ -0,0 +1,18 @@
{
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
// "editor.quickSuggestions": {
// "other": false,
// "comments": false,
// "strings": false
// },
// "editor.hover.enabled": false,
}
+53
View File
@@ -0,0 +1,53 @@
FROM oven/bun:1 AS base
WORKDIR /app
RUN apt-get update -y && apt-get install -y openssl curl
FROM base AS deps-dev
RUN mkdir -p /tmp/dev
COPY package.json bun.lock /tmp/dev/
RUN cd /tmp/dev && bun install --frozen-lockfile
FROM base AS test
COPY --from=deps-dev /tmp/dev/node_modules /app/node_modules
COPY --from=deps-dev /tmp/dev/package.json /app/package.json
COPY --from=deps-dev /tmp/dev/bun.lock /app/bun.lock
COPY . .
RUN bun test
FROM deps-dev AS deps-prod
RUN mkdir -p /tmp/pro
COPY package.json bun.lock /tmp/pro/
RUN cd /tmp/pro && bun install --production --frozen-lockfile
FROM base AS runtime
ARG PORT
ENV PORT=${PORT:-3000}
COPY --from=deps-prod /tmp/pro/node_modules /app/node_modules
COPY --from=deps-prod /tmp/pro/package.json /app/package.json
COPY --from=deps-prod /tmp/pro/bun.lock /app/bun.lock
COPY . /app/
RUN bun prisma generate
RUN chown -R bun:bun /app
USER bun
EXPOSE ${PORT}
ENTRYPOINT ["sh", "-c", "bun prisma migrate deploy && bun start"]
+100
View File
@@ -0,0 +1,100 @@
# URL Shortener Backend
The backend service for the URL Shortener application, built with [Bun](https://bun.sh), [Prisma](https://www.prisma.io/), and [SQLite](https://www.sqlite.org/).
## Tech Stack
- **Runtime**: [Bun](https://bun.sh) - A fast all-in-one JavaScript runtime.
- **Language**: TypeScript
- **ORM**: [Prisma](https://www.prisma.io/) - Next-generation Node.js and TypeScript ORM.
- **Database**: SQLite (via Prisma)
- **Validation**: [Zod](https://zod.dev/) - TypeScript-first schema declaration and validation.
- **Authentication**: [Jose](https://github.com/panva/jose) - JSON Web Almost Everything (JWT, JWS, JWE, JWK, JWA).
- **ID Generation**: [Nanoid](https://github.com/ai/nanoid) - A tiny, secure, URL-friendly, unique string ID generator.
- **Tooling**: [Biome](https://biomejs.dev/) - Fast formatter and linter.
## Architecture
The backend follows a layered architecture to separate concerns and ensure maintainability:
```
src/
├── config/ # Configuration files
├── controllers/ # Request handlers (input validation, response formatting)
├── db/ # Database connection initialization
├── generated/ # Generated Prisma client
├── lib/ # Shared utilities and helpers
├── routes/ # API route definitions
├── schemas/ # Zod schemas for validation
├── services/ # Business logic
└── tests/ # Test files
```
### Request Flow
1. **Routes**: Define the API endpoints and map them to controllers.
2. **Controllers**: Validate the request input using Zod schemas and call the appropriate service.
3. **Services**: Execute the business logic (e.g., creating a short URL, hashing passwords) and interact with the database via Prisma.
4. **Database**: SQLite stores the data.
## Getting Started
### Prerequisites
- [Bun](https://bun.sh) (latest version)
### Installation
Install the dependencies:
```bash
bun install
```
### Environment Setup
Ensure you have a `.env` file in the project root (or `backend` root if running standalone) with the necessary variables (see root `README.md`).
### Database Setup
Initialize the database and generate the Prisma client:
```bash
# Run migrations
bun run db:migrate
# Generate Prisma client
bun run db:generate
```
### Running the Server
Start the development server with hot reloading:
```bash
bun run dev
```
The server will start on `http://0.0.0.0:3000` (default).
To run in production mode:
```bash
bun start
```
## Scripts
- `bun run dev`: Start development server.
- `bun start`: Start production server.
- `bun run format`: Format code using Biome.
- `bun run lint`: Lint code using Biome.
- `bun run check`: Check code for formatting and linting errors.
- `bun run db:studio`: Open Prisma Studio to view/edit data.
- `bun run db:generate`: Generate Prisma client.
- `bun run db:migrate`: Run database migrations.
- `bun run db:deploy`: Deploy migrations (for production).
## API Documentation
The API specification is available in `openapi.json`.
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "asNeeded"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+194
View File
@@ -0,0 +1,194 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "url-shortener",
"dependencies": {
"@prisma/adapter-libsql": "^6.19.0",
"@prisma/client": "^6.19.0",
"jose": "^6.1.2",
"nanoid": "^5.1.6",
"zod": "^4.1.12",
},
"devDependencies": {
"@biomejs/biome": "2.3.5",
"@types/bun": "latest",
"prisma": "^6.19.0",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"trustedDependencies": [
"@prisma/engines",
"prisma",
"@prisma/client",
],
"packages": {
"@biomejs/biome": ["@biomejs/biome@2.3.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.5", "@biomejs/cli-darwin-x64": "2.3.5", "@biomejs/cli-linux-arm64": "2.3.5", "@biomejs/cli-linux-arm64-musl": "2.3.5", "@biomejs/cli-linux-x64": "2.3.5", "@biomejs/cli-linux-x64-musl": "2.3.5", "@biomejs/cli-win32-arm64": "2.3.5", "@biomejs/cli-win32-x64": "2.3.5" }, "bin": { "biome": "bin/biome" } }, "sha512-HvLhNlIlBIbAV77VysRIBEwp55oM/QAjQEin74QQX9Xb259/XP/D5AGGnZMOyF1el4zcvlNYYR3AyTMUV3ILhg=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fLdTur8cJU33HxHUUsii3GLx/TR0BsfQx8FkeqIiW33cGMtUD56fAtrh+2Fx1uhiCsVZlFh6iLKUU3pniZREQw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-qpT8XDqeUlzrOW8zb4k3tjhT7rmvVRumhi2657I2aGcY4B+Ft5fNwDdZGACzn8zj7/K1fdWjgwYE3i2mSZ+vOA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-u/pybjTBPGBHB66ku4pK1gj+Dxgx7/+Z0jAriZISPX1ocTO8aHh8x8e7Kb1rB4Ms0nA/SzjtNOVJ4exVavQBCw=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-eGUG7+hcLgGnMNl1KHVZUYxahYAhC462jF/wQolqu4qso2MSk32Q+QrpN7eN4jAHAg7FUMIo897muIhK4hXhqg=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-XrIVi9YAW6ye0CGQ+yax0gLfx+BFOtKaNX74n+xHWla6Cl6huUmcKNO7HPx7BiKnJUzrxXY1qYlm7xMvi08X4g=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-awVuycTPpVTH/+WDVnEEYSf6nbCBHf/4wB3lquwT7puhNg8R4XvonWNZzUsfHZrCkjkLhFH/vCZK5jHatD9FEg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-DlBiMlBZZ9eIq4H7RimDSGsYcOtfOIfZOaI5CqsWiSlbTfqbPVfWtCf92wNzx8GNMbu1s7/g3ZZESr6+GwM/SA=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-nUmR8gb6yvrKhtRgzwo/gDimPwnO5a4sCydf8ZS2kHIJhEmSmk+STsusr1LHTuM//wXppBawvSQi2xFXJCdgKQ=="],
"@libsql/client": ["@libsql/client@0.8.1", "", { "dependencies": { "@libsql/core": "^0.8.1", "@libsql/hrana-client": "^0.6.2", "js-base64": "^3.7.5", "libsql": "^0.3.10", "promise-limit": "^2.7.0" } }, "sha512-xGg0F4iTDFpeBZ0r4pA6icGsYa5rG6RAG+i/iLDnpCAnSuTqEWMDdPlVseiq4Z/91lWI9jvvKKiKpovqJ1kZWA=="],
"@libsql/core": ["@libsql/core@0.8.1", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-u6nrj6HZMTPsgJ9EBhLzO2uhqhlHQJQmVHV+0yFLvfGf3oSP8w7TjZCNUgu1G8jHISx6KFi7bmcrdXW9lRt++A=="],
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.3.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rmOqsLcDI65zzxlUOoEiPJLhqmbFsZF6p4UJQ2kMqB+Kc0Rt5/A1OAdOZ/Wo8fQfJWjR1IbkbpEINFioyKf+nQ=="],
"@libsql/darwin-x64": ["@libsql/darwin-x64@0.3.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-q9O55B646zU+644SMmOQL3FIfpmEvdWpRpzubwFc2trsa+zoBlSkHuzU9v/C+UNoPHQVRMP7KQctJ455I/h/xw=="],
"@libsql/hrana-client": ["@libsql/hrana-client@0.6.2", "", { "dependencies": { "@libsql/isomorphic-fetch": "^0.2.1", "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5", "node-fetch": "^3.3.2" } }, "sha512-MWxgD7mXLNf9FXXiM0bc90wCjZSpErWKr5mGza7ERy2FJNNMXd7JIOv+DepBA1FQTIfI8TFO4/QDYgaQC0goNw=="],
"@libsql/isomorphic-fetch": ["@libsql/isomorphic-fetch@0.2.5", "", {}, "sha512-8s/B2TClEHms2yb+JGpsVRTPBfy1ih/Pq6h6gvyaNcYnMVJvgQRY7wAa8U2nD0dppbCuDU5evTNMEhrQ17ZKKg=="],
"@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="],
"@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.3.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-mgeAUU1oqqh57k7I3cQyU6Trpdsdt607eFyEmH5QO7dv303ti+LjUvh1pp21QWV6WX7wZyjeJV1/VzEImB+jRg=="],
"@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.3.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-VEZtxghyK6zwGzU9PHohvNxthruSxBEnRrX7BSL5jQ62tN4n2JNepJ6SdzXp70pdzTfwroOj/eMwiPt94gkVRg=="],
"@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.3.19", "", { "os": "linux", "cpu": "x64" }, "sha512-2t/J7LD5w2f63wGihEO+0GxfTyYIyLGEvTFEsMO16XI5o7IS9vcSHrxsvAJs4w2Pf907uDjmc7fUfMg6L82BrQ=="],
"@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.3.19", "", { "os": "linux", "cpu": "x64" }, "sha512-BLsXyJaL8gZD8+3W2LU08lDEd9MIgGds0yPy5iNPp8tfhXx3pV/Fge2GErN0FC+nzt4DYQtjL+A9GUMglQefXQ=="],
"@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.3.19", "", { "os": "win32", "cpu": "x64" }, "sha512-ay1X9AobE4BpzG0XPw1gplyLZPGHIgJOovvW23gUrukRegiUP62uzhpRbKNogLlUOynyXeq//prHgPXiebUfWg=="],
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
"@prisma/adapter-libsql": ["@prisma/adapter-libsql@6.19.0", "", { "dependencies": { "@libsql/client": "^0.3.5 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", "@prisma/driver-adapter-utils": "6.19.0", "async-mutex": "0.5.0" } }, "sha512-IjyPlslIZD3OtiMn59GkUaI/dY7bhkR8tzStLCFGdotk7TONMavlkRebNygBqnZxvVaECZmhyELEgAwlnZvBIw=="],
"@prisma/client": ["@prisma/client@6.19.0", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g=="],
"@prisma/config": ["@prisma/config@6.19.0", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg=="],
"@prisma/debug": ["@prisma/debug@6.19.0", "", {}, "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA=="],
"@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0" } }, "sha512-VAC/wFebV569Jk7iEqzLxekM2A5toKYAr6cPM2KWVHiRHgyjsh/IHf++Xo67q8uor/JxY8mwOuyQyuxkstSf5w=="],
"@prisma/engines": ["@prisma/engines@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0", "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "@prisma/fetch-engine": "6.19.0", "@prisma/get-platform": "6.19.0" } }, "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw=="],
"@prisma/engines-version": ["@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "", {}, "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ=="],
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0", "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "@prisma/get-platform": "6.19.0" } }, "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ=="],
"@prisma/get-platform": ["@prisma/get-platform@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0" } }, "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/react": ["@types/react@19.2.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="],
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"csstype": ["csstype@3.2.0", "", {}, "sha512-si++xzRAY9iPp60roQiFta7OFbhrgvcthrhlNAGeQptSY25uJjkfUV8OArC3KLocB8JT8ohz+qgxWCmz8RhjIg=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
"jose": ["jose@6.1.2", "", {}, "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ=="],
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
"libsql": ["libsql@0.3.19", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2", "libsql": "^0.3.15" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.3.19", "@libsql/darwin-x64": "0.3.19", "@libsql/linux-arm64-gnu": "0.3.19", "@libsql/linux-arm64-musl": "0.3.19", "@libsql/linux-x64-gnu": "0.3.19", "@libsql/linux-x64-musl": "0.3.19", "@libsql/win32-x64-msvc": "0.3.19" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-Aj5cQ5uk/6fHdmeW0TiXK42FqUlwx7ytmMLPSaUQPin5HKKKuUPD62MAbN4OEweGBBI7q1BekoEN4gPUEL6MZA=="],
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"prisma": ["prisma@6.19.0", "", { "dependencies": { "@prisma/config": "6.19.0", "@prisma/engines": "6.19.0" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw=="],
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
}
}
+99
View File
@@ -0,0 +1,99 @@
@baseUrl = http://localhost:3000
@accessToken = {{login.response.body.accessToken}}
@shortId = {{shortenUrl.response.body.shortId}}
@urlId = {{shortenUrl.response.body.id}}
### Health Check
GET {{baseUrl}}/api/health HTTP/1.1
###
### Register a new user
POST {{baseUrl}}/api/auth/signup HTTP/1.1
Content-Type: application/json
{
"email": "newuser@example.com",
"password": "securepassword",
"name": "New User"
}
###
# @name login
POST {{baseUrl}}/api/auth/login HTTP/1.1
Content-Type: application/json
{
"email": "newuser@example.com",
"password": "securepassword"
}
###
### Get user information
GET {{baseUrl}}/api/user HTTP/1.1
Authorization: Bearer {{accessToken}}
###
### Update user profile
PATCH {{baseUrl}}/api/user HTTP/1.1
Authorization: Bearer {{accessToken}}
Content-Type: application/json
{
"name": "Updated User Name"
}
###
### Update user password
PATCH {{baseUrl}}/api/user/password HTTP/1.1
Authorization: Bearer {{accessToken}}
Content-Type: application/json
{
"password": "newsecurepassword"
}
###
# @name shortenUrl
POST {{baseUrl}}/api/url/shorten HTTP/1.1
Authorization: Bearer {{accessToken}}
Content-Type: application/json
{
"url": "http://example.com/some/long/path"
}
###
### Shorten URL with Alias
POST {{baseUrl}}/api/url/shorten HTTP/1.1
Authorization: Bearer {{accessToken}}
Content-Type: application/json
{
"url": "http://example.com/another/path",
"alias": "myalias"
}
###
### Get user urls
GET {{baseUrl}}/api/url HTTP/1.1
Authorization: Bearer {{accessToken}}
###
### Update URL Alias
PATCH {{baseUrl}}/api/url/{{urlId}} HTTP/1.1
Authorization: Bearer {{accessToken}}
Content-Type: application/json
{
"alias": "updatedalias"
}
###
### Delete a url
DELETE {{baseUrl}}/api/url/{{urlId}} HTTP/1.1
Authorization: Bearer {{accessToken}}
###
### Get a url redirection
GET {{baseUrl}}/{{shortId}} HTTP/1.1
###
+23
View File
@@ -0,0 +1,23 @@
import { errorResponse } from "@/lib/utils"
import { apiRoutes } from "@/routes"
import { config } from "@/config"
export const serverConfig = {
port: config.port,
routes: {
...apiRoutes,
},
fetch() {
return errorResponse("Not Found", 404)
},
error(err: Error) {
console.error("🔥 Critical Error:", err)
return errorResponse("Internal Server Error", 500)
},
}
if (import.meta.main) {
const server = Bun.serve(serverConfig)
console.info(`📑 Swagger UI is available at ${server.port}/api/docs`)
console.log(`🚀 Server running at ${server.url}`)
}
+896
View File
@@ -0,0 +1,896 @@
{
"openapi": "3.1.1",
"info": {
"title": "URL Shortener API",
"version": "1.0.0",
"description": "API for URL Shortener service"
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"schemas": {
"User": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"id",
"name",
"email",
"createdAt"
]
},
"Url": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"url": {
"type": "string",
"format": "uri"
},
"shortId": {
"type": "string"
},
"alias": {
"type": "string",
"nullable": true
},
"clicks": {
"type": "integer"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"userId": {
"type": "string"
}
},
"required": [
"id",
"url",
"shortId",
"clicks",
"createdAt",
"userId"
]
},
"Error": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
]
},
"AuthResponse": {
"type": "object",
"properties": {
"accessToken": {
"type": "string"
},
"refreshToken": {
"type": "string"
}
},
"required": [
"accessToken",
"refreshToken"
]
},
"MessageResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
]
}
}
},
"paths": {
"/api/health": {
"get": {
"summary": "Health Check",
"tags": [
"Health"
],
"responses": {
"200": {
"description": "Server status",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
}
}
}
}
}
}
}
}
},
"/api/auth/signup": {
"post": {
"summary": "Sign up a new user",
"tags": [
"Auth"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
},
"password": {
"type": "string",
"minLength": 8,
"maxLength": 100
},
"name": {
"type": "string",
"minLength": 2,
"maxLength": 100
}
},
"required": [
"email",
"password",
"name"
]
}
}
}
},
"responses": {
"201": {
"description": "User registered successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Registration is disabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "User with this email already exists",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/auth/login": {
"post": {
"summary": "Login user",
"tags": [
"Auth"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
},
"password": {
"type": "string",
"minLength": 1,
"maxLength": 100
}
},
"required": [
"email",
"password"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful login",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthResponse"
}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Invalid email or password",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/url": {
"get": {
"summary": "Get all urls for the authenticated user",
"tags": [
"Url"
],
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "List of user urls",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Url"
}
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No urls found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/url/shorten": {
"post": {
"summary": "Create a shortened URL",
"tags": [
"Url"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"format": "uri"
},
"alias": {
"type": "string",
"maxLength": 30,
"pattern": "^[a-zA-Z0-9]+$"
}
},
"required": [
"url"
]
}
}
}
},
"responses": {
"201": {
"description": "URL shortened successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Url"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Alias already in use",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/url/{id}": {
"patch": {
"summary": "Update a url alias",
"tags": [
"Url"
],
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"alias": {
"type": "string",
"maxLength": 30,
"pattern": "^[a-zA-Z0-9]+$"
}
},
"required": [
"alias"
]
}
}
}
},
"responses": {
"200": {
"description": "Url Updated Successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MessageResponse"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "User or Url Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Alias already in use",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"delete": {
"summary": "Delete a url",
"tags": [
"Url"
],
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Url Deleted Successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MessageResponse"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "User or Url Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/user": {
"get": {
"summary": "Get user details",
"tags": [
"User"
],
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "User details",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "User not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Failed to retrieve user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"patch": {
"summary": "Update user details",
"tags": [
"User"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
},
"name": {
"type": "string",
"minLength": 2,
"maxLength": 100
}
}
}
}
}
},
"responses": {
"200": {
"description": "User updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MessageResponse"
}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "User not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Failed to update user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/user/password": {
"patch": {
"summary": "Update user password",
"tags": [
"User"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"password": {
"type": "string",
"minLength": 8,
"maxLength": 100
}
},
"required": [
"password"
]
}
}
}
},
"responses": {
"200": {
"description": "Password updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MessageResponse"
}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "User not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Failed to update password",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/{shortId}": {
"get": {
"summary": "Redirect to original URL",
"tags": [
"Url"
],
"parameters": [
{
"name": "shortId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"301": {
"description": "Redirect to original URL"
},
"404": {
"description": "Url not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/openapi.json": {
"get": {
"summary": "Get OpenAPI Specification",
"tags": [
"Documentation"
],
"responses": {
"200": {
"description": "OpenAPI JSON",
"content": {
"application/json": {}
}
}
}
}
},
"/api/docs": {
"get": {
"summary": "Get Swagger UI",
"tags": [
"Documentation"
],
"responses": {
"200": {
"description": "Swagger UI HTML",
"content": {
"text/html": {}
}
}
}
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"name": "url-shortener",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"dev": "bun run --watch index.ts --host 0.0.0.0",
"start": "bun run index.ts",
"format": "bunx biome format --write",
"lint": "bunx biome lint --write",
"check": "bunx biome check --write",
"db:studio": "prisma studio",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy"
},
"devDependencies": {
"@biomejs/biome": "2.3.5",
"@types/bun": "latest",
"prisma": "^6.19.0"
},
"peerDependencies": {
"typescript": "^5.9.3"
},
"dependencies": {
"@prisma/adapter-libsql": "^6.19.0",
"@prisma/client": "^6.19.0",
"jose": "^6.1.2",
"nanoid": "^5.1.6",
"zod": "^4.1.12"
},
"trustedDependencies": [
"@prisma/client",
"@prisma/engines",
"prisma"
]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig, env } from "prisma/config"
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
// seed: "prisma/seed.ts",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
})
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Url" (
"id" TEXT NOT NULL PRIMARY KEY,
"url" TEXT NOT NULL,
"shortId" TEXT NOT NULL,
"alias" TEXT,
"clicks" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Url_shortId_key" ON "Url"("shortId");
-- CreateIndex
CREATE UNIQUE INDEX "Url_alias_key" ON "Url"("alias");
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+38
View File
@@ -0,0 +1,38 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
engineType = "client"
runtime = "bun"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String
email String @unique
password String
urls Url[] @relation("UserUrls")
createdAt DateTime @default(now())
}
model Url {
id String @id @default(cuid())
url String
shortId String @unique
alias String? @unique
clicks Int @default(0)
createdAt DateTime @default(now())
user User @relation("UserUrls", fields: [userId], references: [id])
userId String
}
+6
View File
@@ -0,0 +1,6 @@
export const config = {
port: Bun.env.PORT ?? 3000,
signupEnabled: Bun.env.SIGNUP_ENABLED === "true",
jwtSecret: Bun.env.JWT_SECRET ?? "your_jwt_secret",
corsOrigin: Bun.env.CORS_ORIGIN ?? "*",
}
@@ -0,0 +1,68 @@
import { errorResponse, jsonResponse, parseBody } from "@/lib/utils"
import { loginSchema, signupSchema } from "@/schemas"
import {
findUserByEmailService,
loginUserService,
signupUserService,
} from "@/services"
import { config } from "@/config"
/**
* Signup a new user.
*
* @param {Request} req - The request object
* @returns {Promise<Response>} - The response object
*/
export const handleSignup = async (req: Request): Promise<Response> => {
const signupEnabled = config.signupEnabled
if (!signupEnabled) {
return errorResponse("Signup is disabled", 403)
}
const body = await parseBody(req)
const result = signupSchema.safeParse(body)
if (!result.success) {
return errorResponse("Bad Request", 400)
}
const { email, password, name } = result.data
const existingUser = await findUserByEmailService(email)
if (existingUser) {
return errorResponse("User with this email already exists", 409)
}
const user = await signupUserService({ email, password, name })
return jsonResponse(user, 201)
}
/**
* Login a user.
*
* @param {Request} req
* @returns
*/
export const handleLogin = async (req: Request) => {
const body = await parseBody(req)
const result = loginSchema.safeParse(body)
if (!result.success) {
return errorResponse("Bad Request", 400)
}
const { email, password } = result.data
try {
const user = await loginUserService({ email, password })
return jsonResponse(user)
} catch {
return errorResponse("Invalid email or password", 401)
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./auth.controller"
export * from "./url.controller"
export * from "./user.controller"
+203
View File
@@ -0,0 +1,203 @@
import { authenticateToken } from "@/lib/middleware"
import {
errorResponse,
generateShortId,
jsonResponse,
parseBody,
} from "@/lib/utils"
import { urlShortenerSchema } from "@/schemas"
import {
createUrlService,
deleteUrlService,
findUrlByAliasService,
findUrlService,
getUrlsByUserService,
incrementUrlClicksService,
updateUrlService,
} from "@/services"
/**
* Shortens a given URL.
*
* @param {Request} req - The request object containing the URL to shorten.
* @returns {Promise<Response>} The shortened URL.
*/
export const handleUrlShortening = async (req: Request): Promise<Response> => {
const userId = await authenticateToken(req)
if (userId instanceof Response) {
return userId
}
const shortId = generateShortId()
const body = await parseBody(req)
const result = urlShortenerSchema.safeParse({ ...body, shortId })
if (!result.success) {
return errorResponse("Bad Request", 400)
}
const { url, alias } = result.data
const existingAlias = await findUrlByAliasService(alias || "")
if (existingAlias) {
return errorResponse("Alias already in use", 409)
}
const newUrl = await createUrlService(url, shortId, userId, alias)
return jsonResponse(newUrl, 201)
}
/**
* Retrieves all urls associated with the authenticated user.
*
* @param {Request} req - The request object.
* @returns {Promise<Response>} The user's urls.
*/
export const handleGetUrlsByUser = async (req: Request): Promise<Response> => {
const userId = await authenticateToken(req)
if (userId instanceof Response) {
return userId
}
try {
const userUrls = await getUrlsByUserService(userId)
if (!userUrls) {
return errorResponse("No urls found", 404)
}
return jsonResponse(userUrls, 200)
} catch (_error) {
return errorResponse("Internal Server Error", 500)
}
}
/**
* Handles redirection for a given short ID.
*
* @param {Object} param - The parameters object.
* @param {string} param.shortId - The short ID of the url.
* @returns {Promise<Response>} The redirection response.
*/
export const handleUrlRedirection = async (param: {
shortId?: string
}): Promise<Response> => {
const { shortId } = param
if (!shortId) {
return errorResponse("Bad Request", 400)
}
try {
const url = await findUrlService(shortId)
if (!url) {
return errorResponse("Url Not Found", 404)
}
await incrementUrlClicksService(url.id)
return Response.redirect(url.url, 301)
} catch (_error) {
return errorResponse("Internal Server Error", 500)
}
}
/**
* Update a url alias by its ID.
*
* @param {string} id - The ID of the url to update.
* @returns {Promise<Response>} - A response indicating the result of the update.
*/
export const handleUrlUpdate = async (
req: Request,
id: string,
): Promise<Response> => {
const userId = await authenticateToken(req)
if (userId instanceof Response) {
return userId
}
try {
const userUrls = await getUrlsByUserService(userId)
if (!userUrls) {
return errorResponse("User Not Found", 404)
}
const userOwnsUrl = userUrls.find((url) => url.id === id)
if (!userOwnsUrl) {
return errorResponse("Url Not Found", 404)
}
const body = await parseBody(req)
const result = urlShortenerSchema.partial().safeParse(body)
if (!result.success) {
return errorResponse("Bad Request", 400)
}
const { alias } = result.data
if (alias) {
const existingAlias = await findUrlByAliasService(alias)
if (existingAlias && existingAlias.id !== id) {
return errorResponse("Alias already in use", 409)
}
await updateUrlService(userOwnsUrl.id, { alias })
}
return jsonResponse({ message: "Url updated successfully" }, 200)
} catch (_error) {
return errorResponse("Internal Server Error", 500)
}
}
/**
* Delete a url by its ID.
*
* @param {string} id - The ID of the url to delete.
* @returns {Promise<Response>} - A response indicating the result of the deletion.
*/
export const handleUrlDeletion = async (
req: Request,
id: string,
): Promise<Response> => {
console.log("Deleting url with ID:", id)
const userId = await authenticateToken(req)
if (userId instanceof Response) {
return userId
}
try {
const userUrls = await getUrlsByUserService(userId)
if (!userUrls) {
return errorResponse("Not Found", 404)
}
const userOwnsUrl = userUrls.find((url) => url.id === id)
if (!userOwnsUrl) {
return errorResponse("Not Found", 404)
}
await deleteUrlService(userOwnsUrl.id)
return jsonResponse({ message: "Url deleted successfully" }, 200)
} catch (_error) {
return errorResponse("Internal Server Error", 500)
}
}
+105
View File
@@ -0,0 +1,105 @@
import { authenticateToken } from "@/lib/middleware"
import { errorResponse, hashPassword, jsonResponse } from "@/lib/utils"
import { updateUserPasswordSchema, updateUserSchema } from "@/schemas"
import {
findUserByEmailService,
findUserByIdService,
updateUserService,
} from "@/services"
export const handleGetUser = async (req: Request): Promise<Response> => {
const userId = await authenticateToken(req)
if (userId instanceof Response) {
return userId
}
try {
const user = await findUserByIdService(userId)
if (!user) {
return errorResponse("User not found", 404)
}
const { id, name, email, createdAt } = user
return jsonResponse({ id, name, email, createdAt }, 200)
} catch (_serror) {
return errorResponse("Failed to retrieve user", 500)
}
}
export const handleUpdateUser = async (req: Request): Promise<Response> => {
const userId = await authenticateToken(req)
if (userId instanceof Response) {
return userId
}
try {
const user = findUserByIdService(userId)
if (!user) {
return errorResponse("User not found", 404)
}
const body = await req.json()
const response = updateUserSchema.safeParse(body)
if (!response.success) {
return errorResponse("Invalid input data", 400)
}
const { name, email } = response.data
if (email) {
const existingUser = await findUserByEmailService(email)
if (existingUser && existingUser.id !== userId) {
return errorResponse("Email already in use", 409)
}
}
await updateUserService(userId, { name, email })
return jsonResponse({ message: "User updated successfully" }, 200)
} catch (_serror) {
return errorResponse("Failed to update user", 500)
}
}
export const handleUpdateUserPassword = async (
req: Request,
): Promise<Response> => {
const userId = await authenticateToken(req)
if (userId instanceof Response) {
return userId
}
try {
const user = findUserByIdService(userId)
if (!user) {
return errorResponse("User not found", 404)
}
const body = await req.json()
const response = updateUserPasswordSchema.safeParse(body)
if (!response.success) {
return errorResponse("Invalid input data", 400)
}
const { password } = response.data
const hashedPassword = await hashPassword(password)
await updateUserService(userId, { password: hashedPassword })
return jsonResponse({ message: "Password updated successfully" }, 200)
} catch (_serror) {
return errorResponse("Failed to update password", 500)
}
}
+5
View File
@@ -0,0 +1,5 @@
import { PrismaLibSQL } from "@prisma/adapter-libsql"
import { PrismaClient } from "@/generated/prisma/client"
const adapter = new PrismaLibSQL({ url: Bun.env.DATABASE_URL || "" })
export const prisma = new PrismaClient({ adapter })
+9
View File
@@ -0,0 +1,9 @@
export const logger = (req: Request, status: number, duration: number) => {
const color = status >= 400 ? "\x1b[31m" : "\x1b[32m" // Rojo para errores, Verde para OK
const reset = "\x1b[0m"
const timestamp = new Date().toISOString()
console.log(
`[${timestamp}] ${req.method} ${req.url} ${color}${status}${reset} - ${duration.toFixed(2)}ms`,
)
}
+61
View File
@@ -0,0 +1,61 @@
import type { Handler } from "@/lib/types"
import { errorResponse, extractUserIdFromToken } from "@/lib/utils"
/** * Middleware to authenticate JWT token from Authorization header
* @param req
* @returns userId or Response with error
*/
export const authenticateToken = async (
req: Request,
): Promise<string | Response> => {
const authHeader = req.headers.get("Authorization")
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return errorResponse("Unauthorized", 401)
}
const token = authHeader.split(" ")[1]
if (!token) {
return errorResponse("Unauthorized", 401)
}
try {
const decoded = await extractUserIdFromToken(token)
if (!decoded) {
return errorResponse("Unauthorized", 401)
}
return decoded
} catch (_error) {
return errorResponse("Unauthorized", 401)
}
}
/**
* Middleware to log requests and responses
* @param handler
* @returns
*/
export const withLogger = (handler: Handler): Handler => {
return async (req: Request) => {
const start = performance.now()
const url = new URL(req.url)
try {
const response = await handler(req)
const duration = (performance.now() - start).toFixed(2)
const color = response.status >= 400 ? "\x1b[31m" : "\x1b[32m"
console.log(
`[${req.method}] ${url.pathname} -> ${color}${response.status}\x1b[0m (${duration}ms)`,
)
return response
} catch (err) {
console.error(`[ERROR] ${req.method} ${url.pathname}:`, err)
return new Response("Internal Error", { status: 500 })
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import type { Url, User } from "@/generated/prisma/client"
/**Enum for HTTP Methods
*/
export enum HttpMethod {
GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE",
PATCH = "PATCH",
OPTIONS = "OPTIONS",
}
/**Handler type for request handlers
*/
export type Handler = (req: Request) => Response | Promise<Response>
/**Params interface to type route parameters
*/
export interface Params {
[id: string]: string
}
/** RequestParams interface extending the global Request to include route parameters
*/
export interface RequestParams extends globalThis.Request {
params: Params
}
/** Login Response Interface
*/
export interface LoginResponse {
accessToken: string
refreshToken: string
}
/** User Response Interface without password field
*/
export interface UserResponse extends Omit<User, "password"> {}
/** Url Response Interface without userId and clicks fields
*/
export interface UrlResponse extends Omit<Url, "userId" | "clicks"> {}
/** Url Response Interface without userId field
*/
export interface UrlResponseWithClicks extends Omit<Url, "userId"> {}
+77
View File
@@ -0,0 +1,77 @@
import type { BodyInit, ResponseInit } from "undici-types"
import { HttpMethod } from "../types"
import { config } from "@/config"
/**
* * Custom Response class to include CORS headers
*/
export class ClientResponse extends Response {
constructor(body?: BodyInit, init?: ResponseInit) {
// @ts-expect-error Bun Response typing issue
super(body, init)
this.headers.set(
"Access-Control-Allow-Origin",
config.corsOrigin || "null",
)
this.headers.set(
"Access-Control-Allow-Methods",
`${HttpMethod.GET}, ${HttpMethod.POST}, ${HttpMethod.PUT}, ${HttpMethod.DELETE}, ${HttpMethod.PATCH}, ${HttpMethod.OPTIONS}`,
)
this.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With",
)
this.headers.set("Access-Control-Allow-Credentials", "true")
this.headers.set("Access-Control-Max-Age", "86400")
}
}
/**
* JSON response helper
*
* @param {any} data - Data to be sent in the response
* @param {number} status - HTTP status code (default is 200)
* @returns
*/
// biome-ignore lint: this is a simple utility function
export const jsonResponse = (data: any, status: number = 200) => {
return new ClientResponse(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
})
}
/**
* Error response helper
*
* @param {string} message - Error message
* @param {number} status - HTTP status code (default is 400)
* @returns
*/
export const errorResponse = (
message: string = "Bad Request",
status: number = 400,
) => {
return new ClientResponse(JSON.stringify({ message }), {
status,
headers: { "Content-Type": "application/json" },
})
}
/**
* Parse JSON body from Request
*
* @param {Request} req - Incoming request
* @returns {Promise<object>} - Parsed JSON body
*/
export const parseBody = (req: Request): Promise<object> => {
return new Promise((resolve, reject) => {
try {
const body = req.json()
resolve(body)
} catch (error) {
reject(error)
}
})
}
+44
View File
@@ -0,0 +1,44 @@
import { jwtVerify, SignJWT } from "jose"
import { config } from "@/config"
const secretKey = new TextEncoder().encode(config.jwtSecret)
export const generateAccessToken = async (
userId: string,
email: string,
): Promise<string> => {
const token = await new SignJWT({ userId, email })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("2h")
.sign(secretKey)
return token
}
export const generateRefreshToken = async (userId: string): Promise<string> => {
const token = await new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(secretKey)
return token
}
export const verifyToken = async (token: string) => {
try {
const { payload } = await jwtVerify(token, secretKey)
return payload
} catch {
return null
}
}
export const extractUserIdFromToken = async (
token: string,
): Promise<string | null> => {
const payload = await verifyToken(token)
if (payload && typeof payload.userId === "string") {
return payload.userId
}
return null
}
+12
View File
@@ -0,0 +1,12 @@
import { customAlphabet } from "nanoid"
export const generateShortId = (): string => {
const alphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const customNanoid = customAlphabet(alphabet, 6)
return customNanoid()
}
export * from "./api"
export * from "./auth"
export * from "./security"
+15
View File
@@ -0,0 +1,15 @@
export const hashPassword = async (password: string): Promise<string> => {
const hashedPassword = await Bun.password.hash(password, {
algorithm: "bcrypt",
cost: 10,
})
return hashedPassword
}
export const verifyPassword = async (
password: string,
hashedPassword: string,
): Promise<boolean> => {
const isValid = await Bun.password.verify(password, hashedPassword)
return isValid
}
+11
View File
@@ -0,0 +1,11 @@
import { handleLogin, handleSignup } from "@/controllers/auth.controller"
import { HttpMethod } from "@/lib/types"
export const authRoutes = {
"/api/auth/signup": {
[HttpMethod.POST]: handleSignup,
},
"/api/auth/login": {
[HttpMethod.POST]: handleLogin,
},
}
+34
View File
@@ -0,0 +1,34 @@
import { HttpMethod } from "@/lib/types"
const swaggerHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>URL Shortener API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: '/api/openapi.json', // Apunta a tu ruta JSON anterior
dom_id: '#swagger-ui',
});
};
</script>
</body>
</html>
`
export const docRoutes = {
"/api/openapi.json": {
[HttpMethod.GET]: () => new Response(Bun.file("./openapi.json")),
},
"/api/docs": {
[HttpMethod.GET]: () =>
new Response(swaggerHtml, { headers: { "Content-Type": "text/html" } }),
},
}
+51
View File
@@ -0,0 +1,51 @@
import { handleUrlRedirection } from "@/controllers/url.controller"
import { withLogger } from "@/lib/middleware"
import type { RequestParams } from "@/lib/types"
import { HttpMethod } from "@/lib/types"
import { jsonResponse } from "@/lib/utils/api"
import { authRoutes } from "./auth.routes"
import { docRoutes } from "./doc.routes"
import { urlRoutes } from "./url.routes"
import { userRoutes } from "./user.routes"
const rawRoutes = {
...authRoutes,
...urlRoutes,
...userRoutes,
...docRoutes,
// Link Redirection Handler
"/:shortId": {
[HttpMethod.GET]: async (req: RequestParams) => {
const { shortId } = req.params
return handleUrlRedirection({ shortId })
},
},
// Root redirection to /docs
"/api": {
[HttpMethod.GET]: () => Response.redirect("/api/docs", 302),
},
// CORS Preflight Handler
"/api/*": {
OPTIONS: () => jsonResponse({}, 200),
},
// Health Check Endpoint
"/api/health": {
[HttpMethod.GET]: () => Response.json({ status: "ok" }),
},
}
const applyGlobalMiddleware = (routes: any) => {
const processedRoutes: any = {}
for (const [path, methods] of Object.entries(routes)) {
processedRoutes[path] = {}
for (const [method, handler] of Object.entries(methods as HttpMethod)) {
// @ts-expect-error - TypeScript issue
processedRoutes[path][method] = withLogger(handler)
}
}
return processedRoutes
}
export const apiRoutes = applyGlobalMiddleware(rawRoutes)
+32
View File
@@ -0,0 +1,32 @@
import {
handleGetUrlsByUser,
handleUrlDeletion,
handleUrlShortening,
handleUrlUpdate,
} from "@/controllers"
import { HttpMethod, type RequestParams } from "@/lib/types"
export const urlRoutes = {
"/api/url": {
[HttpMethod.GET]: async (req: Request) => {
return handleGetUrlsByUser(req)
},
},
"/api/url/:id": {
[HttpMethod.PATCH]: async (req: RequestParams) => {
const { id } = req.params
if (!id) return
return handleUrlUpdate(req, id)
},
[HttpMethod.DELETE]: async (req: RequestParams) => {
const { id } = req.params
if (!id) return
return handleUrlDeletion(req, id)
},
},
"/api/url/shorten": {
[HttpMethod.POST]: async (req: Request) => {
return handleUrlShortening(req)
},
},
}
+22
View File
@@ -0,0 +1,22 @@
import {
handleGetUser,
handleUpdateUser,
handleUpdateUserPassword,
} from "@/controllers/user.controller"
import { HttpMethod } from "@/lib/types"
export const userRoutes = {
"/api/user": {
[HttpMethod.GET]: async (req: Request) => {
return handleGetUser(req)
},
[HttpMethod.PATCH]: async (req: Request) => {
return handleUpdateUser(req)
},
},
"/api/user/password": {
[HttpMethod.PATCH]: async (req: Request) => {
return handleUpdateUserPassword(req)
},
},
}
+19
View File
@@ -0,0 +1,19 @@
import * as z from "zod"
export const emailSchema = z.email()
export const passwordSchema = z.string().min(8).max(100)
export const signupSchema = z.object({
email: emailSchema,
password: passwordSchema,
name: z.string().min(2).max(100),
})
export type RegisterInput = z.infer<typeof signupSchema>
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1).max(100),
})
export type LoginInput = z.infer<typeof loginSchema>
+3
View File
@@ -0,0 +1,3 @@
export * from "./auth.schema"
export * from "./url.schema"
export * from "./user.schema"
+24
View File
@@ -0,0 +1,24 @@
import * as z from "zod"
const urlSchema = z.url({
protocol: /^https?$/,
hostname: z.regexes.domain,
})
export const shortIdSchema = z.string().length(6)
const aliasSchema = z
.string()
.max(30)
.optional()
.nullable()
.refine((val) => {
if (val === null || val === undefined) return true
return /^[a-zA-Z0-9]+$/.test(val)
}, "Alias can only contain alphanumeric characters")
export const urlShortenerSchema = z.object({
url: urlSchema,
shortId: shortIdSchema,
alias: aliasSchema,
})
export type UrlShortenerInput = z.infer<typeof urlShortenerSchema>
+12
View File
@@ -0,0 +1,12 @@
import * as z from "zod"
import { emailSchema, passwordSchema } from "./auth.schema"
export const updateUserSchema = z.object({
email: emailSchema.optional(),
name: z.string().min(2).max(100).optional(),
password: passwordSchema.optional(),
})
export const updateUserPasswordSchema = z.object({
password: passwordSchema,
})
+73
View File
@@ -0,0 +1,73 @@
import type { LoginResponse, UserResponse } from "@/lib/types"
import {
generateAccessToken,
generateRefreshToken,
hashPassword,
verifyPassword,
} from "@/lib/utils"
import { loginSchema, signupSchema } from "@/schemas"
import { createUserService, findUserByEmailService } from "@/services"
/**
* Signup a new user.
*
* @param {Object} data - Signup data
* @param {string} data.email - User's email
* @param {string} data.password - User's password
* @param {string} data.name - User's name
* @returns {Promise<UserResponse>} - The signed-up user without password
*/
export const signupUserService = async (data: {
email: string
password: string
name: string
}): Promise<UserResponse> => {
const { email, password, name } = signupSchema.parse(data)
const existingUser = await findUserByEmailService(email)
if (existingUser) {
throw new Error("User with this email already exists")
}
const hashedPassword = await hashPassword(password)
const user = await createUserService(email, hashedPassword, name)
const { password: _, ...userWithoutPassword } = user
return userWithoutPassword
}
/**
* Login a user.
*
* @param {Object} data - Login data
* @param {string} data.email - User's email
* @param {string} data.password - User's password
* @returns {Promise<UserLoginResponses>} - The logged-in user without password
*/
export const loginUserService = async (data: {
email: string
password: string
}): Promise<LoginResponse> => {
const { email, password } = loginSchema.parse(data)
const user = await findUserByEmailService(email)
if (!user) {
throw new Error("Invalid email or password")
}
const isValidPassword = await verifyPassword(password, user.password)
if (!isValidPassword) {
throw new Error("Invalid email or password")
}
const accessToken = await generateAccessToken(user.id, user.email)
const refreshToken = await generateRefreshToken(user.id)
return { accessToken, refreshToken }
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./auth.service"
export * from "./url.service"
export * from "./user.service"
+154
View File
@@ -0,0 +1,154 @@
import { prisma as db } from "@/db"
import type { UrlResponse, UrlResponseWithClicks } from "@/lib/types"
/**
* Create a new url.
*
* @param {string} url - The original URL
* @param {string} shortId - The short ID for the URL
* @param {string} userId - The ID of the user creating the url
* @param {string} alias - The alias for the URL
* @returns {Promise<UrlResponse>} - The created url object
*/
export const createUrlService = async (
url: string,
shortId: string,
userId: string,
alias?: string | null,
): Promise<UrlResponse> => {
return await db.url.create({
data: {
url,
shortId,
alias: alias || null,
user: { connect: { id: userId } },
},
select: {
id: true,
url: true,
shortId: true,
alias: true,
createdAt: true,
},
})
}
/**
* Find a url by its short ID.
*
* @param {string} shortId - The short ID of the url
* @returns {Promise<Url | null>} - The found url object or null if not found
*/
export const findUrlService = async (
shortId: string,
): Promise<UrlResponse | null> => {
return await db.url.findFirst({
where: {
OR: [{ shortId }, { alias: shortId }],
},
select: {
id: true,
url: true,
shortId: true,
alias: true,
createdAt: true,
},
})
}
/** * Find a url by its alias.
*
* @param {string} alias - The alias of the url
* @returns {Promise<Url | null>} - The found url object or null if not found
*/
export const findUrlByAliasService = async (
alias: string,
): Promise<UrlResponse | null> => {
return await db.url.findUnique({
where: { alias },
select: {
id: true,
url: true,
shortId: true,
alias: true,
createdAt: true,
},
})
}
/**
* Get all urls for a user.
*
* @param {string} userId - The ID of the user
* @returns {Promise<UrlResponseWithClicks[]>} - An array of url objects
*/
export const getUrlsByUserService = async (
userId: string,
): Promise<UrlResponseWithClicks[]> => {
return await db.url.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
select: {
id: true,
url: true,
shortId: true,
alias: true,
createdAt: true,
clicks: true,
},
})
}
/**
* Delete a url by its ID.
*
* @param {string} urlId - The ID of the url to delete
* @returns {Promise<void>} - A promise that resolves when the url is deleted
*/
export const deleteUrlService = async (urlId: string): Promise<void> => {
await db.url.delete({
where: { id: urlId },
})
}
/**
* Update a url's URL or alias.
*
* @param {string} urlId - The ID of the url to update
* @param {Object} data - The data to update
* @param {string} [data.url] - The new URL
* @param {string} [data.alias] - The new alias
* @returns {Promise<UrlResponse>} - The updated url object
*/
export const updateUrlService = async (
urlId: string,
data: { url?: string; alias?: string },
): Promise<UrlResponse> => {
return await db.url.update({
where: { id: urlId },
data,
select: {
id: true,
url: true,
shortId: true,
alias: true,
createdAt: true,
},
})
}
/** * Increment the click count for a url by its short ID.
*
* @param {string} id - The ID of the url
* @returns {Promise<void>} - A promise that resolves when the operation is complete
*/
export const incrementUrlClicksService = async (id: string): Promise<void> => {
await db.url.updateMany({
where: { id },
data: {
clicks: {
increment: 1,
},
},
})
}
+69
View File
@@ -0,0 +1,69 @@
import { prisma as db } from "@/db"
import type { User } from "@/generated/prisma/client"
/**
* Create a new user.
*
* @param {string} email - User's email
* @param {string} hashedPassword - User's hashed password
* @returns {Promise<User>} - The created user object
*/
export const createUserService = async (
email: string,
hashedPassword: string,
name: string,
): Promise<User> => {
const user = await db.user.create({
data: {
email,
password: hashedPassword,
name,
},
})
return user
}
/**
* Find a user by email.
*
* @param {string} email - User's email
* @returns {Promise<User | null>} - The user object or null if not found
*/
export const findUserByEmailService = async (
email: string,
): Promise<User | null> => {
const user = await db.user.findUnique({ where: { email } })
return user
}
/**
* Find a user by ID.
*
* @param {string} id - User's ID
* @returns {Promise<User | null>} - The user object or null if not found
*/
export const findUserByIdService = async (id: string): Promise<User | null> => {
const user = await db.user.findUnique({ where: { id } })
return user
}
/**
* Update a user's information.
*
* @param {string} id - User's ID
* @param {Object} data - Data to update
* @param {string} [data.email] - New email
* @param {string} [data.name] - New name
* @param {string} [data.password] - New password
* @returns {Promise<User>} - The updated user object
*/
export const updateUserService = async (
id: string,
data: Partial<Pick<User, "email" | "name" | "password">>,
): Promise<User> => {
const user = await db.user.update({
where: { id },
data,
})
return user
}
+108
View File
@@ -0,0 +1,108 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
import { serverConfig } from "../../index"
import { cleanTestDB, setupTestDB } from "./test-utils"
describe("URL Shortener API", () => {
let BASE_URL: string
beforeAll(async () => {
await setupTestDB()
const server = Bun.serve({
...serverConfig,
port: 0,
})
BASE_URL = `http://localhost:${server.port}`
})
afterAll(async () => {
await cleanTestDB()
})
it("Register a new user", async () => {
const req = await fetch(`${BASE_URL}/api/auth/signup`, {
method: "POST",
body: JSON.stringify({
email: "test@example.com",
name: "Test User",
password: "password",
}),
headers: { "Content-Type": "application/json" },
})
expect(req.status).toBe(201)
const data = await req.json()
expect(data).toHaveProperty("id")
expect(data).toHaveProperty("email", "test@example.com")
expect(data).toHaveProperty("name", "Test User")
expect(data).toHaveProperty("createdAt")
})
it("Register with existing email", async () => {
const req = await fetch(`${BASE_URL}/api/auth/signup`, {
method: "POST",
body: JSON.stringify({
email: "test@example.com",
name: "Test User",
password: "password",
}),
headers: { "Content-Type": "application/json" },
})
expect(req.status).toBe(409)
const data = await req.json()
expect(data).toHaveProperty(
"message",
"User with this email already exists",
)
})
it("Invalid registration data", async () => {
const req = await fetch(`${BASE_URL}/api/auth/signup`, {
method: "POST",
body: JSON.stringify({
email: "test@example.com",
password: "password",
}),
headers: { "Content-Type": "application/json" },
})
expect(req.status).toBe(400)
const data = await req.json()
expect(data).toHaveProperty("message", "Bad Request")
})
it("Login with valid credentials", async () => {
const req = await fetch(`${BASE_URL}/api/auth/login`, {
method: "POST",
body: JSON.stringify({ email: "test@example.com", password: "password" }),
})
expect(req.status).toBe(200)
const data = await req.json()
expect(data).toHaveProperty("accessToken")
expect(data).toHaveProperty("refreshToken")
})
it("Login with invalid credentials", async () => {
const req = await fetch(`${BASE_URL}/api/auth/login`, {
method: "POST",
body: JSON.stringify({ email: "bad@email.com", password: "wrong" }),
headers: { "Content-Type": "application/json" },
})
expect(req.status).not.toBe(200)
})
it("Login with invalid email", async () => {
const req = await fetch(`${BASE_URL}/api/auth/login`, {
method: "POST",
body: JSON.stringify({ email: "bad", password: "wrong" }),
headers: { "Content-Type": "application/json" },
})
expect(req.status).toBe(400)
const data = await req.json()
expect(data).toHaveProperty("message", "Bad Request")
})
})
+32
View File
@@ -0,0 +1,32 @@
import { beforeAll, describe, expect, it } from "bun:test"
import { serverConfig } from "../.."
describe("Documentation API", () => {
let BASE_URL: string
beforeAll(async () => {
const server = Bun.serve({
...serverConfig,
port: 0,
})
BASE_URL = `http://localhost:${server.port}`
})
it("Get OpenAPI Specification", async () => {
const req = await fetch(`${BASE_URL}/api/openapi.json`)
expect(req.status).toBe(200)
const data = await req.json()
expect(data).toHaveProperty("openapi")
expect(data).toHaveProperty("info")
expect(data).toHaveProperty("paths")
})
it("Get Swagger UI", async () => {
const req = await fetch(`${BASE_URL}/api/docs`)
expect(req.status).toBe(200)
const text = await req.text()
expect(text).toContain("<!DOCTYPE html>")
expect(text).toContain("URL Shortener API")
})
})
+27
View File
@@ -0,0 +1,27 @@
import { beforeAll, describe, expect, it } from "bun:test"
import { serverConfig } from "../.."
describe("Main API", () => {
let BASE_URL: string
beforeAll(async () => {
const server = Bun.serve({
...serverConfig,
port: 0,
})
BASE_URL = `http://localhost:${server.port}`
})
it("Health check", async () => {
const req = await fetch(`${BASE_URL}/api/health`)
expect(req.status).toBe(200)
const data = await req.json()
expect(data).toEqual({ status: "ok" })
})
it("Not Found", async () => {
const req = await fetch(`${BASE_URL}/not-found-route`)
expect(req.status).toBe(404)
})
})
+61
View File
@@ -0,0 +1,61 @@
import { prisma } from "@/db"
import {
generateAccessToken,
generateRefreshToken,
hashPassword,
} from "@/lib/utils"
/**
* Execute migrations to set up the test database schema.
* Use db push to avoid generating client code during tests.
*/
export const setupTestDB = async () => {
try {
const proc = Bun.spawn(["bunx", "--bun", "prisma", "migrate", "deploy"], {
stdout: "ignore",
stderr: "ignore",
env: {
...process.env,
NODE_ENV: "test",
DATABASE_URL: "file:./test.db",
},
})
await proc.exited
} catch (error) {
console.error("Error ", error)
}
}
/**
* Clean up the test database by deleting all records from relevant tables.
* ALERT: Order of deletions matters due to foreign key constraints.
*/
export const cleanTestDB = async () => {
// Adjust these names to match your actual models
const deleteUrls = prisma.url.deleteMany()
const deleteUsers = prisma.user.deleteMany()
// Use a transaction to ensure complete cleanup
await prisma.$transaction([deleteUrls, deleteUsers])
}
/**
* Create a new user and generate an authentication token for testing.
*
* @returns {Promise<{ user: User; token: string }>} - The created user and auth token
*/
export const createAuthenticatedUser = async () => {
const user = await prisma.user.create({
data: {
email: `test_${Date.now()}@example.com`,
password: await hashPassword("testpassword"),
name: "Test User",
},
})
const accessToken = await generateAccessToken(user.id, user.email)
const refreshToken = await generateRefreshToken(user.id)
return { accessToken, refreshToken }
}
+246
View File
@@ -0,0 +1,246 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
import { serverConfig } from "../.."
import { cleanTestDB, createAuthenticatedUser, setupTestDB } from "./test-utils"
describe("Url Endpoints (Protected)", () => {
let accessToken = ""
let BASE_URL: string
beforeAll(async () => {
await setupTestDB()
const server = Bun.serve({
...serverConfig,
port: 0,
})
BASE_URL = `http://localhost:${server.port}`
const authData = await createAuthenticatedUser()
accessToken = authData.accessToken
})
afterAll(async () => {
await cleanTestDB()
})
it("Generate a short url", async () => {
const response = await fetch(`${BASE_URL}/api/url/shorten`, {
method: "POST",
body: JSON.stringify({ url: "https://google.com" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
expect(response.status).toBe(201)
const data = await response.json()
expect(data).toHaveProperty("id")
expect(data).toHaveProperty("shortId")
expect(data).toHaveProperty("url", "https://google.com")
expect(data).toHaveProperty("alias", null)
expect(data).toHaveProperty("createdAt")
})
it("Generate a short url with alias", async () => {
const response = await fetch(`${BASE_URL}/api/url/shorten`, {
method: "POST",
body: JSON.stringify({ url: "https://google.com", alias: "google" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
expect(response.status).toBe(201)
const data = await response.json()
expect(data).toHaveProperty("shortId")
expect(data).toHaveProperty("url", "https://google.com")
expect(data).toHaveProperty("alias", "google")
})
it("Generate a short url with invalid URL", async () => {
const response = await fetch(`${BASE_URL}/api/url/shorten`, {
method: "POST",
body: JSON.stringify({ url: "invalid-url" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
expect(response.status).toBe(400)
const data = await response.json()
expect(data).toHaveProperty("message", "Bad Request")
})
it("Generate a short url with duplicate alias", async () => {
await fetch(`${BASE_URL}/api/url/shorten`, {
method: "POST",
body: JSON.stringify({ url: "https://example.com", alias: "example" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
const response = await fetch(`${BASE_URL}/api/url/shorten`, {
method: "POST",
body: JSON.stringify({ url: "https://another.com", alias: "example" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
expect(response.status).toBe(409)
const data = await response.json()
expect(data).toHaveProperty("message", "Alias already in use")
})
it("Get urls for authenticated user", async () => {
const response: Response | null = await fetch(`${BASE_URL}/api/url`, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
expect(response.status).toBe(200)
const data = await response.json()
expect(Array.isArray(data)).toBe(true)
// @ts-expect-error: unknown type
expect(data.length).toBeGreaterThanOrEqual(0)
})
it("Update a url alias", async () => {
const createResponse = await fetch(`${BASE_URL}/api/url/shorten`, {
method: "POST",
body: JSON.stringify({
url: "https://update-test.com",
alias: "firstAlias",
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
const createdUrl = await createResponse.json()
const updateResponse = await fetch(
// @ts-expect-error: unknown type
`${BASE_URL}/api/url/${createdUrl.id}`,
{
method: "PATCH",
body: JSON.stringify({ alias: "updateAlias" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
},
)
expect(updateResponse.status).toBe(200)
const updatedUrl = await updateResponse.json()
expect(updatedUrl).toHaveProperty("message", "Url updated successfully")
})
it("Delete a url", async () => {
// First, create a url to delete
const createResponse = await fetch(`${BASE_URL}/api/url/shorten`, {
method: "POST",
body: JSON.stringify({ url: "https://delete-test.com" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
const createdUrl = await createResponse.json()
// Now, delete the url
const deleteResponse = await fetch(
// @ts-expect-error: unknown type
`${BASE_URL}/api/url/${createdUrl.id}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
expect(deleteResponse.status).toBe(200)
})
it("Redirect to original url", async () => {
const createResponse = await fetch(`${BASE_URL}/api/url/shorten`, {
method: "POST",
body: JSON.stringify({ url: "https://example.com" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
const createdUrl = await createResponse.json()
const redirectResponse = await fetch(
// @ts-expect-error: unknown type
`${BASE_URL}/${createdUrl.shortId}`,
{
method: "GET",
redirect: "manual",
},
)
expect(redirectResponse.status).toBe(301)
expect(redirectResponse.headers.get("Location")).toBe("https://example.com")
})
it("Fail to update alias to existing one", async () => {
await fetch(`${BASE_URL}/api/url/shorten`, {
method: "POST",
body: JSON.stringify({ url: "https://one.com", alias: "alias1" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
const createResponse = await fetch(`${BASE_URL}/api/url/shorten`, {
method: "POST",
body: JSON.stringify({ url: "https://two.com", alias: "alias2" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
const createdUrl = await createResponse.json()
// Try to update second url to alias1
const updateResponse = await fetch(
// @ts-expect-error: unknown type
`${BASE_URL}/api/url/${createdUrl.id}`,
{
method: "PATCH",
body: JSON.stringify({ alias: "alias1" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
},
)
expect(updateResponse.status).toBe(409)
})
it("Fail to delete non-existent url", async () => {
const response = await fetch(`${BASE_URL}/api/url/nonexistentid`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
expect(response.status).toBe(404)
})
})
+119
View File
@@ -0,0 +1,119 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
import { serverConfig } from "../.."
import { cleanTestDB, createAuthenticatedUser, setupTestDB } from "./test-utils"
describe("User Endpoints (Protected)", () => {
let accessToken = ""
let BASE_URL: string
beforeAll(async () => {
await setupTestDB()
const server = Bun.serve({
...serverConfig,
port: 0,
})
BASE_URL = `http://localhost:${server.port}`
const authData = await createAuthenticatedUser()
accessToken = authData.accessToken
})
afterAll(async () => {
await cleanTestDB()
})
it("Get user info", async () => {
const response = await fetch(`${BASE_URL}/api/user`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty("id")
expect(data).toHaveProperty("name", "Test User")
expect(data).toHaveProperty("email")
expect(data).toHaveProperty("createdAt")
})
it("Update user info", async () => {
const newName = "Updated Test User"
const newEmail = `updated_${Date.now()}@example.com`
const response = await fetch(`${BASE_URL}/api/user`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
name: newName,
email: newEmail,
}),
})
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty("message", "User updated successfully")
})
it("Fail to update email to existing one", async () => {
await fetch(`${BASE_URL}/api/auth/signup`, {
method: "POST",
body: JSON.stringify({
email: "existing@example.com",
name: "Existing User",
password: "password",
}),
headers: { "Content-Type": "application/json" },
})
const response = await fetch(`${BASE_URL}/api/user`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
email: "existing@example.com",
}),
})
expect(response.status).toBe(409)
const data = await response.json()
expect(data).toHaveProperty("message", "Email already in use")
})
it("Update user password", async () => {
const response = await fetch(`${BASE_URL}/api/user/password`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
password: "newtestpassword",
}),
})
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty("message", "Password updated successfully")
})
it("Fails to get user info without token", async () => {
const response = await fetch(`${BASE_URL}/api/user`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
expect(response.status).toBe(401)
const data = await response.json()
expect(data).toHaveProperty("message", "Unauthorized")
})
})
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
// Path aliases (uncomment if needed)
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
}
}
}
+41
View File
@@ -0,0 +1,41 @@
services:
traefik:
image: traefik:3.5.4
container_name: ${STACK_NAME?Variable not set}_traefik
restart: unless-stopped
env_file:
- .env
ports:
- "80:80"
- "8080:8080"
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.constraint-label=proxy
- traefik.http.services.${STACK_NAME?Variable not set}-proxy.loadbalancer.server.port=8080
- traefik.http.routers.${STACK_NAME?Variable not set}-proxy.rule=Host(`traefik.${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}-proxy.entrypoints=web
command:
# === Providers ===
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=proxy
# === Entrypoints ===
- --entrypoints.web.address=:80
- --entrypoints.internal.address=:8000
# === API and Dashboard ===
- --api.dashboard=true
- --api
- --accesslog
- --log
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxy
networks:
proxy:
name: proxy
+92
View File
@@ -0,0 +1,92 @@
services:
backend:
build: ./backend
container_name: ${STACK_NAME?Variable not set}_backend
restart: always
volumes:
- db_data:/app/prisma
entrypoint: sh -c "bun prisma migrate deploy && bun start"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-3000}/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- proxy
env_file: .env
environment:
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET:-your_jwt_secret}
- SIGNUP_ENABLED=${SIGNUP_ENABLED:-"true"}
- CORS_ORIGIN=${CORS_ORIGIN:-*}
- PORT=${PORT:-3000}
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.constraint-label=proxy
- traefik.http.services.${STACK_NAME}-backend.loadbalancer.server.port=3000
- "traefik.http.routers.${STACK_NAME}-backend-http.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.${STACK_NAME}-backend-http.entrypoints=web"
- "traefik.http.routers.${STACK_NAME}-backend-http.service=${STACK_NAME}-backend"
studio:
build: ./backend
container_name: ${STACK_NAME}_prisma_studio
restart: always
depends_on:
backend:
condition: service_healthy
restart: true
volumes:
- db_data:/app/prisma
entrypoint: sh -c "bun prisma studio --hostname 0.0.0.0"
expose:
- 5555
networks:
- proxy
env_file: .env
environment:
- DATABASE_URL=${DATABASE_URL}
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.services.${STACK_NAME}-studio.loadbalancer.server.port=5555
- "traefik.http.routers.${STACK_NAME}-studio.rule=Host(`studio.${DOMAIN}`)"
- "traefik.http.routers.${STACK_NAME}-studio.entrypoints=web"
- "traefik.http.routers.${STACK_NAME}-studio.service=${STACK_NAME}-studio"
- traefik.http.routers.${STACK_NAME}-studio.middlewares=${STACK_NAME}-studio-basicauth
- traefik.http.middlewares.${STACK_NAME}-studio-basicauth.basicauth.users=${PRISMA_USERNAME}:${PRISMA_PASSWORD}
frontend:
build:
context: ./frontend
args:
- VITE_DOMAIN=${VITE_DOMAIN}
- VITE_API_BASE_URL=${VITE_API_BASE_URL}
- VITE_SIGNUP_ENABLED=${SIGNUP_ENABLED}
container_name: ${STACK_NAME?Variable not set}_frontend
restart: always
depends_on:
backend:
condition: service_healthy
restart: true
networks:
- proxy
env_file: .env
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.constraint-label=proxy
- traefik.http.services.${STACK_NAME}-frontend.loadbalancer.server.port=80
- "traefik.http.routers.${STACK_NAME}-frontend-http.rule=Host(`short.${DOMAIN}`)"
- "traefik.http.routers.${STACK_NAME}-frontend-http.entrypoints=web"
- "traefik.http.routers.${STACK_NAME}-frontend-http.service=${STACK_NAME}-frontend"
volumes:
db_data:
driver: local
networks:
proxy:
name: proxy
+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
.devcontainer
.vscode
.dockerignore
node_modules
dist
Dockerfile*
compose*
README.md
*.http
+14
View File
@@ -0,0 +1,14 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.formatOnSave": true
}
+33
View File
@@ -0,0 +1,33 @@
FROM oven/bun:1 AS base
WORKDIR /app
ARG VITE_DOMAIN
ARG VITE_API_BASE_URL
ARG VITE_SIGNUP_ENABLED
ENV VITE_DOMAIN=${VITE_DOMAIN}
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ENV VITE_SIGNUP_ENABLED=${VITE_SIGNUP_ENABLED}
ENV NODE_ENV=production
FROM base AS build
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM nginx:alpine AS server
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80/tcp
ENTRYPOINT ["nginx", "-g", "daemon off;"]
+73
View File
@@ -0,0 +1,73 @@
# URL Shortener Frontend
The frontend application for the URL Shortener, built with [React](https://react.dev/) and [Vite](https://vitejs.dev/).
## Tech Stack
- **Framework**: [React 19](https://react.dev/)
- **Build Tool**: [Vite](https://vitejs.dev/)
- **Language**: TypeScript
- **Styling**: [Tailwind CSS 4](https://tailwindcss.com/)
- **UI Components**: [Shadcn UI](https://ui.shadcn.com/)
- **Forms**: [React Hook Form](https://react-hook-form.com/) + [Zod](https://zod.dev/)
- **Routing**: [React Router](https://reactrouter.com/)
- **Tooling**: [Biome](https://biomejs.dev/) - Fast formatter and linter.
## Architecture
The frontend is organized using a feature-based architecture to ensure scalability and maintainability:
```
src/
├── components/ # Shared UI components (buttons, inputs, etc.)
├── config/ # Configuration files
├── context/ # React Context providers (Auth, Theme, etc.)
├── features/ # Feature-specific code (Auth, Dashboard, etc.)
├── hooks/ # Custom React hooks
├── layouts/ # Page layouts (AuthLayout, DashboardLayout)
├── lib/ # Shared utilities and helpers
├── pages/ # Page components (mapped to routes)
└── routes.tsx # Route definitions
```
### Key Directories
- **`features/`**: Contains logic specific to a domain (e.g., `auth`, `url`). Each feature folder typically contains its own components, hooks, and services.
- **`components/ui/`**: Houses reusable UI components styled with Tailwind CSS and Shadcn UI.
## Getting Started
### Prerequisites
- [Bun](https://bun.sh) (latest version)
### Installation
Install the dependencies:
```bash
bun install
```
### Environment Setup
Ensure you have a `.env` file in the project root (or `frontend` root if running standalone) with the necessary variables (see root `README.md`).
### Running the Server
Start the development server:
```bash
bun run dev
```
The application will be available at `http://localhost:5173` (or the port specified by Vite).
## Scripts
- `bun run dev`: Start development server.
- `bun run build`: Build for production.
- `bun run preview`: Preview production build.
- `bun run lint`: Lint code using Biome.
- `bun run format`: Format code using Biome.
- `bun run check`: Check code for formatting and linting errors.
+123
View File
@@ -0,0 +1,123 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"nursery": {
"useSortedClasses": {
"fix": "safe",
"level": "warn",
"options": {
"attributes": ["classList"],
"functions": ["cn", "clsx", "cva", "tw"]
}
}
}
},
"includes": ["**", "!dist"]
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "asNeeded"
},
"globals": []
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"overrides": [
{
"includes": ["**/*.{ts,tsx}"],
"linter": {
"rules": {
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error"
},
"correctness": {
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"useIsNan": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error"
}
}
}
}
],
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+498
View File
@@ -0,0 +1,498 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "frontend",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.554.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.1",
"react-router": "^7.9.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"zod": "^4.1.12",
},
"devDependencies": {
"@biomejs/biome": "2.3.6",
"@types/node": "^24.10.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"globals": "^16.5.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.2.2",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
"@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@biomejs/biome": ["@biomejs/biome@2.3.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.6", "@biomejs/cli-darwin-x64": "2.3.6", "@biomejs/cli-linux-arm64": "2.3.6", "@biomejs/cli-linux-arm64-musl": "2.3.6", "@biomejs/cli-linux-x64": "2.3.6", "@biomejs/cli-linux-x64-musl": "2.3.6", "@biomejs/cli-win32-arm64": "2.3.6", "@biomejs/cli-win32-x64": "2.3.6" }, "bin": { "biome": "bin/biome" } }, "sha512-oqUhWyU6tae0MFsr/7iLe++QWRg+6jtUhlx9/0GmCWDYFFrK366sBLamNM7D9Y+c7YSynUFKr8lpEp1r6Sk7eA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P4JWE5d8UayBxYe197QJwyW4ZHp0B+zvRIGCusOm1WbxmlhpAQA1zEqQuunHgSIzvyEEp4TVxiKGXNFZPg7r9Q=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-I4rTebj+F/L9K93IU7yTFs8nQ6EhaCOivxduRha4w4WEZK80yoZ8OAdR1F33m4yJ/NfUuTUbP/Wjs+vKjlCoWA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-JjYy83eVBnvuINZiqyFO7xx72v8Srh4hsgaacSBCjC22DwM6+ZvnX1/fj8/SBiLuUOfZ8YhU2pfq2Dzakeyg1A=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oK1NpIXIixbJ/4Tcx40cwiieqah6rRUtMGOHDeK2ToT7yUFVEvXUGRKqH0O4hqZ9tW8TcXNZKfgRH6xrsjVtGg=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjPXzy5yN9wusIoX+8Zp4p6cL8r0NzJCXg/4r1KLVveIPXd2jKVlqZ6ZyzEq385WwU3OX5KOwQYLQsOc788waQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-QvxB8GHQeaO4FCtwJpJjCgJkbHBbWxRHUxQlod+xeaYE6gtJdSkYkuxdKAQUZEOIsec+PeaDAhW9xjzYbwmOFA=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-YM7hLHpwjdt8R7+O2zS1Vo2cKgqEeptiXB1tWW1rgjN5LlpZovBVKtg7zfwfRrFx3i08aNZThYpTcowpTlczug=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-psgNEYgMAobY5h+QHRBVR9xvg2KocFuBKm6axZWB/aD12NWhQjiVFQUjV6wMXhlH4iT0Q9c3yK5JFRiDC/rzHA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.2", "", { "os": "android", "cpu": "arm" }, "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.2", "", { "os": "android", "cpu": "arm64" }, "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.2", "", { "os": "linux", "cpu": "arm" }, "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.2", "", { "os": "linux", "cpu": "arm" }, "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.2", "", { "os": "linux", "cpu": "none" }, "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.2", "", { "os": "linux", "cpu": "none" }, "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.2", "", { "os": "linux", "cpu": "none" }, "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.2", "", { "os": "linux", "cpu": "x64" }, "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.2", "", { "os": "none", "cpu": "arm64" }, "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.2", "", { "os": "win32", "cpu": "x64" }, "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.2", "", { "os": "win32", "cpu": "x64" }, "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "dependencies": { "@emnapi/core": "^1.6.0", "@emnapi/runtime": "^1.6.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.17", "", { "dependencies": { "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "tailwindcss": "4.1.17" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.1", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.47", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA=="],
"browserslist": ["browserslist@4.28.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", "electron-to-chromium": "^1.5.249", "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001755", "", {}, "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.255", "", {}, "sha512-Z9oIp4HrFF/cZkDPMpz2XSuVpc1THDpT4dlmATFlJUIBVCy9Vap5/rIXsASP1CscBacBqhabwh8vLctqBwEerQ=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.554.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
"react-hook-form": ["react-hook-form@7.66.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-router": ["react-router@7.9.6", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"rollup": ["rollup@4.53.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.2", "@rollup/rollup-android-arm64": "4.53.2", "@rollup/rollup-darwin-arm64": "4.53.2", "@rollup/rollup-darwin-x64": "4.53.2", "@rollup/rollup-freebsd-arm64": "4.53.2", "@rollup/rollup-freebsd-x64": "4.53.2", "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", "@rollup/rollup-linux-arm-musleabihf": "4.53.2", "@rollup/rollup-linux-arm64-gnu": "4.53.2", "@rollup/rollup-linux-arm64-musl": "4.53.2", "@rollup/rollup-linux-loong64-gnu": "4.53.2", "@rollup/rollup-linux-ppc64-gnu": "4.53.2", "@rollup/rollup-linux-riscv64-gnu": "4.53.2", "@rollup/rollup-linux-riscv64-musl": "4.53.2", "@rollup/rollup-linux-s390x-gnu": "4.53.2", "@rollup/rollup-linux-x64-gnu": "4.53.2", "@rollup/rollup-linux-x64-musl": "4.53.2", "@rollup/rollup-openharmony-arm64": "4.53.2", "@rollup/rollup-win32-arm64-msvc": "4.53.2", "@rollup/rollup-win32-ia32-msvc": "4.53.2", "@rollup/rollup-win32-x64-gnu": "4.53.2", "@rollup/rollup-win32-x64-msvc": "4.53.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"vite": ["vite@7.2.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>URL Shortener</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:css|js|jpg|svg|png|gif|ico)$ {
expires 1y;
add_header Cache-Control "public";
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "bunx --bun vite --host",
"build": "vite build",
"lint": "bunx biome lint",
"format": "bunx biome format",
"check": "bunx biome check",
"preview": "bunx --bun vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.554.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.1",
"react-router": "^7.9.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"zod": "^4.1.12"
},
"devDependencies": {
"@biomejs/biome": "2.3.6",
"@types/node": "^24.10.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"globals": "^16.5.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.2.2"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,37 @@
import React from "react"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "../ui/card"
interface CustomCardProps {
title?: string
description?: string
footer?: string | React.ReactNode
children: React.ReactNode
[key: string]: any
}
export function CustomCard({
title,
description,
footer,
children,
...props
}: CustomCardProps) {
const { className } = props
return (
<Card className={`w-full ${className}`}>
<CardHeader className="space-y-2">
<CardTitle className="text-2xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
<CardFooter className="flex justify-center">{footer}</CardFooter>
</Card>
)
}
+1
View File
@@ -0,0 +1 @@
export * from "./custom.card"
+18
View File
@@ -0,0 +1,18 @@
import { Link } from "react-router"
import { ThemeToggle, UserMenu } from "@/components/layout"
export function Header() {
return (
<header className="border-border border-b bg-card">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<h1 className="font-semibold text-xl">
<Link to="/">URL Shortener</Link>
</h1>
<div className="flex items-center gap-2">
<ThemeToggle />
<UserMenu />
</div>
</div>
</header>
)
}
+3
View File
@@ -0,0 +1,3 @@
export { Header } from "./header"
export { ThemeToggle } from "./theme.toggle"
export { UserMenu } from "./user.menu"
@@ -0,0 +1,26 @@
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useTheme } from "@/context/theme.provider"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const toggleTheme = () => {
setTheme(theme === "light" ? "dark" : "light")
}
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
title={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
>
{theme === "light" ? (
<Moon className="h-5 w-5" />
) : (
<Sun className="h-5 w-5" />
)}
</Button>
)
}
@@ -0,0 +1,45 @@
import { CircleUserIcon, LogOut, User2Icon } from "lucide-react"
import { Link } from "react-router"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const handleLogout = () => {
localStorage.removeItem("accessToken")
window.location.href = "/login"
}
export function UserMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<CircleUserIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-36">
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link to={"/profile"} className="flex cursor-pointer items-center">
<User2Icon className="mr-2" />
Profile
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem className="flex items-center">
<button onClick={handleLogout} className="flex items-center">
<LogOut className="mr-4" />
Logout
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./protected.route"
export * from "./public.route"
@@ -0,0 +1,20 @@
import { Navigate, Outlet, useLocation } from "react-router"
import { useAuth } from "@/context/auth.context"
import { LoaderCircle } from "lucide-react"
export const ProtectedRoute = () => {
const { isAuthenticated, isLoading } = useAuth()
const location = useLocation()
if (isLoading) {
return <div className="loading-screen">
<LoaderCircle className="loading-screen-icon" />
</div>
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <Outlet />
}
@@ -0,0 +1,17 @@
import { Navigate, Outlet } from "react-router"
import { useAuth } from "@/context/auth.context"
import { LoaderCircle } from "lucide-react"
export const PublicRoute = () => {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) return <div className="loading-screen">
<LoaderCircle className="loading-screen-icon" />
</div>
if (isAuthenticated) {
return <Navigate to="/" replace />
}
return <Outlet />
}
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+246
View File
@@ -0,0 +1,246 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+38
View File
@@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+7
View File
@@ -0,0 +1,7 @@
export const DOMAIN =
import.meta.env.VITE_DOMAIN || "http://localhost:5173"
export const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL || "http://localhost:3000/api"
export const SIGNUP_ENABLED = import.meta.env.VITE_SIGNUP_ENABLED === "true"
+61
View File
@@ -0,0 +1,61 @@
import React, { createContext, useContext, useEffect, useState } from "react"
import { loginService } from "@/features/auth/auth.api"
import { isTokenExpired } from "@/lib/auth.utils"
interface AuthContextType {
login: (creds: any) => Promise<void>
logout: () => void
isAuthenticated: boolean
isLoading: boolean
}
const AuthContext = createContext<AuthContextType | null>(null)
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState(() => {
const token = localStorage.getItem("accessToken")
return token != null && !isTokenExpired(token)
})
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem("accessToken")
if (token) {
if (isTokenExpired(token)) {
localStorage.removeItem("accessToken")
setIsAuthenticated(false)
} else {
setIsAuthenticated(true)
}
}
setIsLoading(false)
}, [])
const login = async (credentials: any) => {
const data = await loginService(credentials)
if (data.accessToken) {
localStorage.setItem("accessToken", data.accessToken)
} else {
throw new Error("No access token received")
}
}
const logout = () => {
localStorage.removeItem("accessToken")
window.location.href = "/login"
}
return (
<AuthContext.Provider value={{ login, logout, isAuthenticated, isLoading }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) throw new Error("useAuth must be used within an AuthProvider")
return context
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./auth.context"
export * from "./theme.provider"
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "light",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "light",
storageKey = "theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}
+27
View File
@@ -0,0 +1,27 @@
import { apiClient } from "@/lib/api.client"
export const signupService = async (data: {
name: string
email: string
password: string
}) => {
const newUser = await apiClient("/auth/signup", {
method: "POST",
body: data,
})
if (newUser) {
return { ok: true }
} else {
return { ok: false, message: "Registration failed" }
}
}
export const loginService = async (data: {
email: string
password: string
}) => {
return await apiClient("/auth/login", {
method: "POST",
body: data,
})
}
@@ -0,0 +1,34 @@
import React from "react"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "../../../components/ui/card"
interface CustomCardProps {
title?: string
description?: string
footer?: string | React.ReactNode
children: React.ReactNode
}
export function AuthCard({
title,
description,
footer,
children,
}: CustomCardProps) {
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-2 text-center">
<CardTitle className="text-2xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
<CardFooter className="flex justify-center">{footer}</CardFooter>
</Card>
)
}
@@ -0,0 +1,3 @@
export * from "./auth.card"
export * from "./login.form"
export * from "./signup.form"
@@ -0,0 +1,102 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { loginService } from "../auth.api"
import { type LoginInputs, loginSchema } from "../schemas"
export function LoginForm() {
const [isLoading, setIsLoading] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
setError,
} = useForm<LoginInputs>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
})
const onSubmit: SubmitHandler<LoginInputs> = async (data) => {
setIsLoading(true)
try {
const response = await loginService(data)
localStorage.setItem("accessToken", response.accessToken)
location.replace("/")
} catch (error) {
setError("root", {
type: "manual",
message: error instanceof Error ? error.message : "Login failed",
})
} finally {
setIsLoading(false)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<FieldSet>
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="text"
placeholder="admin@example.com"
className="w-full"
{...register("email", { required: true })}
/>
</Field>
{errors.email && (
<FieldDescription className="text-destructive text-sm">
{errors.email.message}
</FieldDescription>
)}
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input
id="password"
type="password"
placeholder="••••••••"
{...register("password", { required: true })}
className="w-full"
/>
</Field>
{errors.password && (
<FieldDescription className="text-destructive text-sm">
{errors.password.message}
</FieldDescription>
)}
<Button
type="submit"
className={`w-full ${isLoading ? "loading" : ""}`}
disabled={isLoading}
>
Sign In {isLoading ? "..." : ""}
</Button>
<div className="text-center">
{errors.root && (
<FieldDescription className="text-destructive text-sm">
{errors.root.message}
</FieldDescription>
)}
</div>
</FieldGroup>
</FieldSet>
</form>
)
}
@@ -0,0 +1,120 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form"
import { useNavigate } from "react-router"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { signupService } from "../auth.api"
import { type RegisterInputs, registerSchema } from "../schemas"
export function SignupForm() {
const [isLoading, setIsLoading] = useState(false)
const navigate = useNavigate()
const {
register,
handleSubmit,
formState: { errors },
setError,
} = useForm<RegisterInputs>({
resolver: zodResolver(registerSchema),
defaultValues: {
email: "",
name: "",
password: "",
},
})
const onSubmit: SubmitHandler<RegisterInputs> = async (data) => {
setIsLoading(true)
try {
const response = await signupService(data)
if (response.ok) {
alert("Signup successful! Please login.")
navigate("/login")
} else {
throw new Error(response.message || "Signup failed")
}
} catch (error) {
setError("root", {
type: "manual",
message: error instanceof Error ? error.message : "Signup failed",
})
} finally {
setIsLoading(false)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<FieldSet>
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="text"
placeholder="admin@example.com"
className="w-full"
{...register("email", { required: true })}
/>
</Field>
{errors.email && (
<FieldDescription className="text-destructive text-sm">
{errors.email.message}
</FieldDescription>
)}
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input
id="name"
type="text"
placeholder="Admin User"
className="w-full"
{...register("name", { required: true })}
/>
</Field>
{errors.name && (
<FieldDescription>{errors.name.message}</FieldDescription>
)}
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input
id="password"
type="password"
placeholder="••••••••"
{...register("password", { required: true })}
className="w-full"
/>
</Field>
{errors.password && (
<FieldDescription className="text-destructive text-sm">
{errors.password.message}
</FieldDescription>
)}
<Button
type="submit"
className={`w-full ${isLoading ? "loading" : ""}`}
disabled={isLoading}
>
Sign Up {isLoading ? "..." : ""}
</Button>
<div className="text-center">
{errors.root && (
<FieldDescription className="text-destructive text-sm">
{errors.root.message}
</FieldDescription>
)}
</div>
</FieldGroup>
</FieldSet>
</form>
)
}
@@ -0,0 +1,7 @@
import { z } from "zod"
export const emailSchema = z.email("Invalid email address")
export const passwordSchema = z
.string()
.min(6, "Password must be at least 6 characters")
@@ -0,0 +1,3 @@
export * from "./auth.common.schemas"
export * from "./login.schemas"
export * from "./signup.schemas"
@@ -0,0 +1,9 @@
import { z } from "zod"
import { emailSchema, passwordSchema } from "./auth.common.schemas"
export const loginSchema = z.object({
email: emailSchema,
password: passwordSchema,
})
export type LoginInputs = z.infer<typeof loginSchema>
@@ -0,0 +1,10 @@
import { z } from "zod"
import { emailSchema, passwordSchema } from "./auth.common.schemas"
export const registerSchema = z.object({
email: emailSchema,
name: z.string().min(2, "Name must be at least 2 characters"),
password: passwordSchema,
})
export type RegisterInputs = z.infer<typeof registerSchema>
@@ -0,0 +1,2 @@
export * from "./password.form"
export * from "./profile.form"
@@ -0,0 +1,104 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { type ChangePasswordInputs, changePasswordSchema } from "../schemas"
import { changePassword } from "../user.api"
export function ChangePasswordCard() {
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isDirty, isSubmitting },
} = useForm<ChangePasswordInputs>({
resolver: zodResolver(changePasswordSchema),
defaultValues: {
password: "",
confirmPassword: "",
},
mode: "onChange",
})
const onSubmit = async (data: ChangePasswordInputs) => {
try {
await changePassword({ password: data.password })
reset()
toast.success("Password updated successfully")
} catch (error) {
setError("root", {
type: "server",
message: (error as Error).message,
})
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<FieldSet>
<FieldGroup>
<Field>
<FieldLabel htmlFor="password" className="font-medium text-sm">
New Password
</FieldLabel>
<Input
id="password"
type="password"
autoComplete="new-password"
{...register("password")}
/>
{errors.password && (
<FieldDescription className="text-destructive text-sm">
{errors.password.message}
</FieldDescription>
)}
</Field>
<Field>
<FieldLabel
htmlFor="confirmPassword"
className="font-medium text-sm"
>
Confirm Password
</FieldLabel>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<FieldDescription className="text-destructive text-sm">
{errors.confirmPassword.message}
</FieldDescription>
)}
</Field>
{errors.root && (
<FieldDescription className="text-destructive text-sm">
{errors.root.message}
</FieldDescription>
)}
<Button
type="submit"
className="w-full"
disabled={isSubmitting || !isDirty}
>
{isSubmitting ? "Updating..." : "Update Password"}
</Button>
</FieldGroup>
</FieldSet>
</form>
)
}
@@ -0,0 +1,98 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { getUser } from "../user.api"
import { type UpdateUserInputs, updateUserSchema } from "../schemas"
import { updateUser } from "../user.api"
import { useEffect } from "react"
export function ProfileForm() {
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isDirty, isSubmitting },
} = useForm<UpdateUserInputs>({
resolver: zodResolver(updateUserSchema),
defaultValues: async () => {
return await getUser()
},
})
useEffect(() => {
async function fetchUser() {
const user = await getUser()
reset(user)
}
fetchUser()
}, [reset])
const onSubmit = async (data: UpdateUserInputs) => {
try {
await updateUser(data)
reset(data)
toast.success("Profile updated successfully")
} catch (error) {
setError("root", {
type: "server",
message: (error as Error).message,
})
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<FieldSet>
<FieldGroup>
<Field>
<FieldLabel htmlFor="name" className="font-medium text-sm">
Name
</FieldLabel>
<Input id="name" {...register("name")} />
{errors.name && (
<FieldDescription className="text-destructive text-sm">
{errors.name.message}
</FieldDescription>
)}
</Field>
<Field>
<FieldLabel htmlFor="email" className="font-medium text-sm">
Email
</FieldLabel>
<Input id="email" {...register("email")} />
{errors.email && (
<FieldDescription className="text-destructive text-sm">
{errors.email.message}
</FieldDescription>
)}
</Field>
{errors.root && (
<FieldDescription className="text-destructive text-sm">
{errors.root.message}
</FieldDescription>
)}
<Button
type="submit"
className="w-full"
disabled={isSubmitting || !isDirty}
>
{isSubmitting ? "Updating..." : "Update Profile"}
</Button>
</FieldGroup>
</FieldSet>
</form>
)
}

Some files were not shown because too many files have changed in this diff Show More