From 721217a8e3dabef709dff2715764e716a223c3cd Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Sun, 23 Nov 2025 20:42:56 +0100 Subject: [PATCH] Initial release --- .devcontainer/backend/Dockerfile | 6 + .devcontainer/backend/devcontainer.json | 24 + .devcontainer/frontend/devcontainer.json | 30 + .env.example | 22 + .gitignore | 51 + README.md | 64 ++ backend/.dockerignore | 11 + backend/.vscode/launch.json | 19 + backend/.vscode/settings.json | 18 + backend/Dockerfile | 53 ++ backend/README.md | 100 ++ backend/biome.json | 35 + backend/bun.lock | 194 ++++ backend/client.http | 99 ++ backend/index.ts | 23 + backend/openapi.json | 896 ++++++++++++++++++ backend/package.json | 37 + backend/prisma.config.ts | 13 + .../20251121140418_init/migration.sql | 29 + backend/prisma/migrations/migration_lock.toml | 3 + backend/prisma/schema.prisma | 38 + backend/src/config/index.ts | 6 + backend/src/controllers/auth.controller.ts | 68 ++ backend/src/controllers/index.ts | 3 + backend/src/controllers/url.controller.ts | 203 ++++ backend/src/controllers/user.controller.ts | 105 ++ backend/src/db/index.ts | 5 + backend/src/lib/logger.ts | 9 + backend/src/lib/middleware.ts | 61 ++ backend/src/lib/types.ts | 41 + backend/src/lib/utils/api.ts | 77 ++ backend/src/lib/utils/auth.ts | 44 + backend/src/lib/utils/index.ts | 12 + backend/src/lib/utils/security.ts | 15 + backend/src/routes/auth.routes.ts | 11 + backend/src/routes/doc.routes.ts | 34 + backend/src/routes/index.ts | 51 + backend/src/routes/url.routes.ts | 32 + backend/src/routes/user.routes.ts | 22 + backend/src/schemas/auth.schema.ts | 19 + backend/src/schemas/index.ts | 3 + backend/src/schemas/url.schema.ts | 24 + backend/src/schemas/user.schema.ts | 12 + backend/src/services/auth.service.ts | 73 ++ backend/src/services/index.ts | 3 + backend/src/services/url.service.ts | 154 +++ backend/src/services/user.service.ts | 69 ++ backend/src/tests/auth.test.ts | 108 +++ backend/src/tests/doc.test.ts | 32 + backend/src/tests/main.test.ts | 27 + backend/src/tests/test-utils.ts | 61 ++ backend/src/tests/url.test.ts | 246 +++++ backend/src/tests/user.test.ts | 119 +++ backend/tsconfig.json | 35 + compose.local.yaml | 41 + compose.yaml | 92 ++ frontend/.dockerignore | 11 + frontend/.vscode/settings.json | 14 + frontend/Dockerfile | 33 + frontend/README.md | 73 ++ frontend/biome.json | 123 +++ frontend/bun.lock | 498 ++++++++++ frontend/components.json | 22 + frontend/index.html | 16 + frontend/nginx.conf | 19 + frontend/package.json | 46 + frontend/public/vite.svg | 1 + .../src/components/common/custom.card.tsx | 37 + frontend/src/components/common/index.ts | 1 + frontend/src/components/layout/header.tsx | 18 + frontend/src/components/layout/index.ts | 3 + .../src/components/layout/theme.toggle.tsx | 26 + frontend/src/components/layout/user.menu.tsx | 45 + frontend/src/components/routes/index.ts | 2 + .../src/components/routes/protected.route.tsx | 20 + .../src/components/routes/public.route.tsx | 17 + frontend/src/components/ui/button.tsx | 60 ++ frontend/src/components/ui/card.tsx | 92 ++ frontend/src/components/ui/dropdown-menu.tsx | 255 +++++ frontend/src/components/ui/field.tsx | 246 +++++ frontend/src/components/ui/input.tsx | 21 + frontend/src/components/ui/label.tsx | 22 + frontend/src/components/ui/separator.tsx | 28 + frontend/src/components/ui/sonner.tsx | 38 + frontend/src/config/index.ts | 7 + frontend/src/context/auth.context.tsx | 61 ++ frontend/src/context/index.ts | 2 + frontend/src/context/theme.provider.tsx | 63 ++ frontend/src/features/auth/auth.api.ts | 27 + .../features/auth/components/auth.card.tsx | 34 + .../src/features/auth/components/index.ts | 3 + .../features/auth/components/login.form.tsx | 102 ++ .../features/auth/components/signup.form.tsx | 120 +++ .../auth/schemas/auth.common.schemas.ts | 7 + frontend/src/features/auth/schemas/index.ts | 3 + .../features/auth/schemas/login.schemas.ts | 9 + .../features/auth/schemas/signup.schemas.ts | 10 + .../src/features/profile/components/index.ts | 2 + .../profile/components/password.form.tsx | 104 ++ .../profile/components/profile.form.tsx | 98 ++ .../src/features/profile/schemas/index.ts | 2 + .../profile/schemas/password.schemas.ts | 14 + .../profile/schemas/profile.schemas.ts | 8 + frontend/src/features/profile/user.api.ts | 19 + frontend/src/features/url/components/index.ts | 2 + .../src/features/url/components/url.form.tsx | 108 +++ .../src/features/url/components/url.list.tsx | 139 +++ .../src/features/url/schemas/url.schema.ts | 24 + frontend/src/features/url/types/url.types.ts | 21 + frontend/src/features/url/url.api.ts | 26 + frontend/src/hooks/useTitle.ts | 7 + frontend/src/index.css | 131 +++ frontend/src/layouts/app.layout.tsx | 15 + frontend/src/layouts/auth.layout.tsx | 9 + frontend/src/layouts/index.ts | 2 + frontend/src/lib/api.client.ts | 65 ++ frontend/src/lib/auth.utils.ts | 13 + frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 16 + frontend/src/pages/index.ts | 5 + frontend/src/pages/login.page.tsx | 35 + frontend/src/pages/notFound.page.tsx | 28 + frontend/src/pages/profile.page.tsx | 29 + frontend/src/pages/signup.page.tsx | 35 + frontend/src/pages/urls.page.tsx | 67 ++ frontend/src/routes.tsx | 59 ++ frontend/tsconfig.app.json | 34 + frontend/tsconfig.json | 19 + frontend/tsconfig.node.json | 26 + frontend/vite.config.ts | 14 + 130 files changed, 7099 insertions(+) create mode 100644 .devcontainer/backend/Dockerfile create mode 100644 .devcontainer/backend/devcontainer.json create mode 100644 .devcontainer/frontend/devcontainer.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/.vscode/launch.json create mode 100644 backend/.vscode/settings.json create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/biome.json create mode 100644 backend/bun.lock create mode 100644 backend/client.http create mode 100644 backend/index.ts create mode 100644 backend/openapi.json create mode 100644 backend/package.json create mode 100644 backend/prisma.config.ts create mode 100644 backend/prisma/migrations/20251121140418_init/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/src/config/index.ts create mode 100644 backend/src/controllers/auth.controller.ts create mode 100644 backend/src/controllers/index.ts create mode 100644 backend/src/controllers/url.controller.ts create mode 100644 backend/src/controllers/user.controller.ts create mode 100644 backend/src/db/index.ts create mode 100644 backend/src/lib/logger.ts create mode 100644 backend/src/lib/middleware.ts create mode 100644 backend/src/lib/types.ts create mode 100644 backend/src/lib/utils/api.ts create mode 100644 backend/src/lib/utils/auth.ts create mode 100644 backend/src/lib/utils/index.ts create mode 100644 backend/src/lib/utils/security.ts create mode 100644 backend/src/routes/auth.routes.ts create mode 100644 backend/src/routes/doc.routes.ts create mode 100644 backend/src/routes/index.ts create mode 100644 backend/src/routes/url.routes.ts create mode 100644 backend/src/routes/user.routes.ts create mode 100644 backend/src/schemas/auth.schema.ts create mode 100644 backend/src/schemas/index.ts create mode 100644 backend/src/schemas/url.schema.ts create mode 100644 backend/src/schemas/user.schema.ts create mode 100644 backend/src/services/auth.service.ts create mode 100644 backend/src/services/index.ts create mode 100644 backend/src/services/url.service.ts create mode 100644 backend/src/services/user.service.ts create mode 100644 backend/src/tests/auth.test.ts create mode 100644 backend/src/tests/doc.test.ts create mode 100644 backend/src/tests/main.test.ts create mode 100644 backend/src/tests/test-utils.ts create mode 100644 backend/src/tests/url.test.ts create mode 100644 backend/src/tests/user.test.ts create mode 100644 backend/tsconfig.json create mode 100644 compose.local.yaml create mode 100644 compose.yaml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.vscode/settings.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/biome.json create mode 100644 frontend/bun.lock create mode 100644 frontend/components.json create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/components/common/custom.card.tsx create mode 100644 frontend/src/components/common/index.ts create mode 100644 frontend/src/components/layout/header.tsx create mode 100644 frontend/src/components/layout/index.ts create mode 100644 frontend/src/components/layout/theme.toggle.tsx create mode 100644 frontend/src/components/layout/user.menu.tsx create mode 100644 frontend/src/components/routes/index.ts create mode 100644 frontend/src/components/routes/protected.route.tsx create mode 100644 frontend/src/components/routes/public.route.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/field.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/config/index.ts create mode 100644 frontend/src/context/auth.context.tsx create mode 100644 frontend/src/context/index.ts create mode 100644 frontend/src/context/theme.provider.tsx create mode 100644 frontend/src/features/auth/auth.api.ts create mode 100644 frontend/src/features/auth/components/auth.card.tsx create mode 100644 frontend/src/features/auth/components/index.ts create mode 100644 frontend/src/features/auth/components/login.form.tsx create mode 100644 frontend/src/features/auth/components/signup.form.tsx create mode 100644 frontend/src/features/auth/schemas/auth.common.schemas.ts create mode 100644 frontend/src/features/auth/schemas/index.ts create mode 100644 frontend/src/features/auth/schemas/login.schemas.ts create mode 100644 frontend/src/features/auth/schemas/signup.schemas.ts create mode 100644 frontend/src/features/profile/components/index.ts create mode 100644 frontend/src/features/profile/components/password.form.tsx create mode 100644 frontend/src/features/profile/components/profile.form.tsx create mode 100644 frontend/src/features/profile/schemas/index.ts create mode 100644 frontend/src/features/profile/schemas/password.schemas.ts create mode 100644 frontend/src/features/profile/schemas/profile.schemas.ts create mode 100644 frontend/src/features/profile/user.api.ts create mode 100644 frontend/src/features/url/components/index.ts create mode 100644 frontend/src/features/url/components/url.form.tsx create mode 100644 frontend/src/features/url/components/url.list.tsx create mode 100644 frontend/src/features/url/schemas/url.schema.ts create mode 100644 frontend/src/features/url/types/url.types.ts create mode 100644 frontend/src/features/url/url.api.ts create mode 100644 frontend/src/hooks/useTitle.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/layouts/app.layout.tsx create mode 100644 frontend/src/layouts/auth.layout.tsx create mode 100644 frontend/src/layouts/index.ts create mode 100644 frontend/src/lib/api.client.ts create mode 100644 frontend/src/lib/auth.utils.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/index.ts create mode 100644 frontend/src/pages/login.page.tsx create mode 100644 frontend/src/pages/notFound.page.tsx create mode 100644 frontend/src/pages/profile.page.tsx create mode 100644 frontend/src/pages/signup.page.tsx create mode 100644 frontend/src/pages/urls.page.tsx create mode 100644 frontend/src/routes.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.devcontainer/backend/Dockerfile b/.devcontainer/backend/Dockerfile new file mode 100644 index 0000000..f50731a --- /dev/null +++ b/.devcontainer/backend/Dockerfile @@ -0,0 +1,6 @@ +FROM imbios/bun-node:1.3.2-iron-slim AS base + +RUN apt update && \ + apt install -y openssl + +USER node \ No newline at end of file diff --git a/.devcontainer/backend/devcontainer.json b/.devcontainer/backend/devcontainer.json new file mode 100644 index 0000000..92804d8 --- /dev/null +++ b/.devcontainer/backend/devcontainer.json @@ -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" +} \ No newline at end of file diff --git a/.devcontainer/frontend/devcontainer.json b/.devcontainer/frontend/devcontainer.json new file mode 100644 index 0000000..e0deb1f --- /dev/null +++ b/.devcontainer/frontend/devcontainer.json @@ -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" +} \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cc4a379 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2cd8df --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a59209 --- /dev/null +++ b/README.md @@ -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 + 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. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..80cf715 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.devcontainer +.vscode +.dockerignore +node_modules +dist +Dockerfile* +compose* +README.md +*.http \ No newline at end of file diff --git a/backend/.vscode/launch.json b/backend/.vscode/launch.json new file mode 100644 index 0000000..622987f --- /dev/null +++ b/backend/.vscode/launch.json @@ -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": [] +} \ No newline at end of file diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json new file mode 100644 index 0000000..b4c0a16 --- /dev/null +++ b/backend/.vscode/settings.json @@ -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, +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..30c5b62 --- /dev/null +++ b/backend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..8323b96 --- /dev/null +++ b/backend/README.md @@ -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`. diff --git a/backend/biome.json b/backend/biome.json new file mode 100644 index 0000000..86234d9 --- /dev/null +++ b/backend/biome.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/backend/bun.lock b/backend/bun.lock new file mode 100644 index 0000000..b1301e9 --- /dev/null +++ b/backend/bun.lock @@ -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=="], + } +} diff --git a/backend/client.http b/backend/client.http new file mode 100644 index 0000000..5525489 --- /dev/null +++ b/backend/client.http @@ -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 +### diff --git a/backend/index.ts b/backend/index.ts new file mode 100644 index 0000000..bb292c1 --- /dev/null +++ b/backend/index.ts @@ -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}`) +} diff --git a/backend/openapi.json b/backend/openapi.json new file mode 100644 index 0000000..8211ea0 --- /dev/null +++ b/backend/openapi.json @@ -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": {} + } + } + } + } + } + } +} \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..34494ec --- /dev/null +++ b/backend/package.json @@ -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" + ] +} \ No newline at end of file diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts new file mode 100644 index 0000000..746277b --- /dev/null +++ b/backend/prisma.config.ts @@ -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"), + }, +}) diff --git a/backend/prisma/migrations/20251121140418_init/migration.sql b/backend/prisma/migrations/20251121140418_init/migration.sql new file mode 100644 index 0000000..fd17a54 --- /dev/null +++ b/backend/prisma/migrations/20251121140418_init/migration.sql @@ -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"); diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -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" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..fc18489 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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 +} diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts new file mode 100644 index 0000000..1ac06f5 --- /dev/null +++ b/backend/src/config/index.ts @@ -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 ?? "*", +} \ No newline at end of file diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts new file mode 100644 index 0000000..9e8e66b --- /dev/null +++ b/backend/src/controllers/auth.controller.ts @@ -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} - The response object + */ +export const handleSignup = async (req: Request): Promise => { + 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) + } +} diff --git a/backend/src/controllers/index.ts b/backend/src/controllers/index.ts new file mode 100644 index 0000000..9ced627 --- /dev/null +++ b/backend/src/controllers/index.ts @@ -0,0 +1,3 @@ +export * from "./auth.controller" +export * from "./url.controller" +export * from "./user.controller" diff --git a/backend/src/controllers/url.controller.ts b/backend/src/controllers/url.controller.ts new file mode 100644 index 0000000..f3602ea --- /dev/null +++ b/backend/src/controllers/url.controller.ts @@ -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} The shortened URL. + */ +export const handleUrlShortening = async (req: Request): Promise => { + 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} The user's urls. + */ +export const handleGetUrlsByUser = async (req: Request): Promise => { + 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} The redirection response. + */ +export const handleUrlRedirection = async (param: { + shortId?: string +}): Promise => { + 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} - A response indicating the result of the update. + */ +export const handleUrlUpdate = async ( + req: Request, + id: string, +): Promise => { + 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} - A response indicating the result of the deletion. + */ +export const handleUrlDeletion = async ( + req: Request, + id: string, +): Promise => { + 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) + } +} diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts new file mode 100644 index 0000000..ce7d3af --- /dev/null +++ b/backend/src/controllers/user.controller.ts @@ -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 => { + 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 => { + 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 => { + 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) + } +} diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts new file mode 100644 index 0000000..b96170b --- /dev/null +++ b/backend/src/db/index.ts @@ -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 }) diff --git a/backend/src/lib/logger.ts b/backend/src/lib/logger.ts new file mode 100644 index 0000000..b5cfe65 --- /dev/null +++ b/backend/src/lib/logger.ts @@ -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`, + ) +} diff --git a/backend/src/lib/middleware.ts b/backend/src/lib/middleware.ts new file mode 100644 index 0000000..8b83ba3 --- /dev/null +++ b/backend/src/lib/middleware.ts @@ -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 => { + 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 }) + } + } +} diff --git a/backend/src/lib/types.ts b/backend/src/lib/types.ts new file mode 100644 index 0000000..4e47305 --- /dev/null +++ b/backend/src/lib/types.ts @@ -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 +/**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 {} +/** Url Response Interface without userId and clicks fields + */ +export interface UrlResponse extends Omit {} +/** Url Response Interface without userId field + */ +export interface UrlResponseWithClicks extends Omit {} diff --git a/backend/src/lib/utils/api.ts b/backend/src/lib/utils/api.ts new file mode 100644 index 0000000..fd9caa2 --- /dev/null +++ b/backend/src/lib/utils/api.ts @@ -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} - Parsed JSON body + */ +export const parseBody = (req: Request): Promise => { + return new Promise((resolve, reject) => { + try { + const body = req.json() + resolve(body) + } catch (error) { + reject(error) + } + }) +} diff --git a/backend/src/lib/utils/auth.ts b/backend/src/lib/utils/auth.ts new file mode 100644 index 0000000..a9e70bc --- /dev/null +++ b/backend/src/lib/utils/auth.ts @@ -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 => { + const token = await new SignJWT({ userId, email }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("2h") + .sign(secretKey) + return token +} + +export const generateRefreshToken = async (userId: string): Promise => { + 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 => { + const payload = await verifyToken(token) + if (payload && typeof payload.userId === "string") { + return payload.userId + } + return null +} diff --git a/backend/src/lib/utils/index.ts b/backend/src/lib/utils/index.ts new file mode 100644 index 0000000..4a51dde --- /dev/null +++ b/backend/src/lib/utils/index.ts @@ -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" diff --git a/backend/src/lib/utils/security.ts b/backend/src/lib/utils/security.ts new file mode 100644 index 0000000..0145283 --- /dev/null +++ b/backend/src/lib/utils/security.ts @@ -0,0 +1,15 @@ +export const hashPassword = async (password: string): Promise => { + const hashedPassword = await Bun.password.hash(password, { + algorithm: "bcrypt", + cost: 10, + }) + return hashedPassword +} + +export const verifyPassword = async ( + password: string, + hashedPassword: string, +): Promise => { + const isValid = await Bun.password.verify(password, hashedPassword) + return isValid +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts new file mode 100644 index 0000000..ab0d0a4 --- /dev/null +++ b/backend/src/routes/auth.routes.ts @@ -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, + }, +} diff --git a/backend/src/routes/doc.routes.ts b/backend/src/routes/doc.routes.ts new file mode 100644 index 0000000..62fcb83 --- /dev/null +++ b/backend/src/routes/doc.routes.ts @@ -0,0 +1,34 @@ +import { HttpMethod } from "@/lib/types" + +const swaggerHtml = ` + + + + + URL Shortener API + + + +
+ + + + + ` + +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" } }), + }, +} diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts new file mode 100644 index 0000000..f9a78f4 --- /dev/null +++ b/backend/src/routes/index.ts @@ -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) diff --git a/backend/src/routes/url.routes.ts b/backend/src/routes/url.routes.ts new file mode 100644 index 0000000..9031536 --- /dev/null +++ b/backend/src/routes/url.routes.ts @@ -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) + }, + }, +} diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts new file mode 100644 index 0000000..f4ba880 --- /dev/null +++ b/backend/src/routes/user.routes.ts @@ -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) + }, + }, +} diff --git a/backend/src/schemas/auth.schema.ts b/backend/src/schemas/auth.schema.ts new file mode 100644 index 0000000..3beea1c --- /dev/null +++ b/backend/src/schemas/auth.schema.ts @@ -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 + +export const loginSchema = z.object({ + email: emailSchema, + password: z.string().min(1).max(100), +}) + +export type LoginInput = z.infer diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts new file mode 100644 index 0000000..3501075 --- /dev/null +++ b/backend/src/schemas/index.ts @@ -0,0 +1,3 @@ +export * from "./auth.schema" +export * from "./url.schema" +export * from "./user.schema" diff --git a/backend/src/schemas/url.schema.ts b/backend/src/schemas/url.schema.ts new file mode 100644 index 0000000..3b14a70 --- /dev/null +++ b/backend/src/schemas/url.schema.ts @@ -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 diff --git a/backend/src/schemas/user.schema.ts b/backend/src/schemas/user.schema.ts new file mode 100644 index 0000000..0aceb9f --- /dev/null +++ b/backend/src/schemas/user.schema.ts @@ -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, +}) diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts new file mode 100644 index 0000000..32f5430 --- /dev/null +++ b/backend/src/services/auth.service.ts @@ -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} - The signed-up user without password + */ +export const signupUserService = async (data: { + email: string + password: string + name: string +}): Promise => { + 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} - The logged-in user without password + */ +export const loginUserService = async (data: { + email: string + password: string +}): Promise => { + 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 } +} diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts new file mode 100644 index 0000000..7825fca --- /dev/null +++ b/backend/src/services/index.ts @@ -0,0 +1,3 @@ +export * from "./auth.service" +export * from "./url.service" +export * from "./user.service" diff --git a/backend/src/services/url.service.ts b/backend/src/services/url.service.ts new file mode 100644 index 0000000..ac00b73 --- /dev/null +++ b/backend/src/services/url.service.ts @@ -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} - The created url object + */ +export const createUrlService = async ( + url: string, + shortId: string, + userId: string, + alias?: string | null, +): Promise => { + 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} - The found url object or null if not found + */ +export const findUrlService = async ( + shortId: string, +): Promise => { + 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} - The found url object or null if not found + */ +export const findUrlByAliasService = async ( + alias: string, +): Promise => { + 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} - An array of url objects + */ +export const getUrlsByUserService = async ( + userId: string, +): Promise => { + 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} - A promise that resolves when the url is deleted + */ +export const deleteUrlService = async (urlId: string): Promise => { + 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} - The updated url object + */ +export const updateUrlService = async ( + urlId: string, + data: { url?: string; alias?: string }, +): Promise => { + 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} - A promise that resolves when the operation is complete + */ +export const incrementUrlClicksService = async (id: string): Promise => { + await db.url.updateMany({ + where: { id }, + data: { + clicks: { + increment: 1, + }, + }, + }) +} diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts new file mode 100644 index 0000000..310fc1d --- /dev/null +++ b/backend/src/services/user.service.ts @@ -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} - The created user object + */ +export const createUserService = async ( + email: string, + hashedPassword: string, + name: string, +): Promise => { + 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} - The user object or null if not found + */ +export const findUserByEmailService = async ( + email: string, +): Promise => { + const user = await db.user.findUnique({ where: { email } }) + return user +} + +/** + * Find a user by ID. + * + * @param {string} id - User's ID + * @returns {Promise} - The user object or null if not found + */ +export const findUserByIdService = async (id: string): Promise => { + 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} - The updated user object + */ +export const updateUserService = async ( + id: string, + data: Partial>, +): Promise => { + const user = await db.user.update({ + where: { id }, + data, + }) + return user +} diff --git a/backend/src/tests/auth.test.ts b/backend/src/tests/auth.test.ts new file mode 100644 index 0000000..297eda8 --- /dev/null +++ b/backend/src/tests/auth.test.ts @@ -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") + }) +}) diff --git a/backend/src/tests/doc.test.ts b/backend/src/tests/doc.test.ts new file mode 100644 index 0000000..81e7b1b --- /dev/null +++ b/backend/src/tests/doc.test.ts @@ -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("") + expect(text).toContain("URL Shortener API") + }) +}) diff --git a/backend/src/tests/main.test.ts b/backend/src/tests/main.test.ts new file mode 100644 index 0000000..51e1026 --- /dev/null +++ b/backend/src/tests/main.test.ts @@ -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) + }) +}) diff --git a/backend/src/tests/test-utils.ts b/backend/src/tests/test-utils.ts new file mode 100644 index 0000000..c2e0fcc --- /dev/null +++ b/backend/src/tests/test-utils.ts @@ -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 } +} diff --git a/backend/src/tests/url.test.ts b/backend/src/tests/url.test.ts new file mode 100644 index 0000000..c488758 --- /dev/null +++ b/backend/src/tests/url.test.ts @@ -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) + }) +}) diff --git a/backend/src/tests/user.test.ts b/backend/src/tests/user.test.ts new file mode 100644 index 0000000..7a900bc --- /dev/null +++ b/backend/src/tests/user.test.ts @@ -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") + }) +}) diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..c7ddb44 --- /dev/null +++ b/backend/tsconfig.json @@ -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": { + "@/*": ["./*"] + } + } +} diff --git a/compose.local.yaml b/compose.local.yaml new file mode 100644 index 0000000..c9793d7 --- /dev/null +++ b/compose.local.yaml @@ -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 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..65b6dbe --- /dev/null +++ b/compose.yaml @@ -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 \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..80cf715 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.devcontainer +.vscode +.dockerignore +node_modules +dist +Dockerfile* +compose* +README.md +*.http \ No newline at end of file diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 0000000..e77ef8d --- /dev/null +++ b/frontend/.vscode/settings.json @@ -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 +} \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..fc7f4bc --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..90a6d8e --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/biome.json b/frontend/biome.json new file mode 100644 index 0000000..4dea1a0 --- /dev/null +++ b/frontend/biome.json @@ -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" + } + } + } +} diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 0000000..62af5a6 --- /dev/null +++ b/frontend/bun.lock @@ -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=="], + } +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..2b0833f --- /dev/null +++ b/frontend/components.json @@ -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": {} +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4baeffe --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + URL Shortener + + + +
+ + + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..a3d4a8d --- /dev/null +++ b/frontend/nginx.conf @@ -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"; + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4935d81 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/common/custom.card.tsx b/frontend/src/components/common/custom.card.tsx new file mode 100644 index 0000000..c62a6a6 --- /dev/null +++ b/frontend/src/components/common/custom.card.tsx @@ -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 ( + + + {title} + {description} + + {children} + {footer} + + ) +} diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts new file mode 100644 index 0000000..c5f814d --- /dev/null +++ b/frontend/src/components/common/index.ts @@ -0,0 +1 @@ +export * from "./custom.card" diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx new file mode 100644 index 0000000..825c2e2 --- /dev/null +++ b/frontend/src/components/layout/header.tsx @@ -0,0 +1,18 @@ +import { Link } from "react-router" +import { ThemeToggle, UserMenu } from "@/components/layout" + +export function Header() { + return ( +
+
+

+ URL Shortener +

+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/layout/index.ts b/frontend/src/components/layout/index.ts new file mode 100644 index 0000000..45e255d --- /dev/null +++ b/frontend/src/components/layout/index.ts @@ -0,0 +1,3 @@ +export { Header } from "./header" +export { ThemeToggle } from "./theme.toggle" +export { UserMenu } from "./user.menu" diff --git a/frontend/src/components/layout/theme.toggle.tsx b/frontend/src/components/layout/theme.toggle.tsx new file mode 100644 index 0000000..2fa29d5 --- /dev/null +++ b/frontend/src/components/layout/theme.toggle.tsx @@ -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 ( + + ) +} diff --git a/frontend/src/components/layout/user.menu.tsx b/frontend/src/components/layout/user.menu.tsx new file mode 100644 index 0000000..6a38a79 --- /dev/null +++ b/frontend/src/components/layout/user.menu.tsx @@ -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 ( + + + + + + + + + + Profile + + + + + + + + + + ) +} diff --git a/frontend/src/components/routes/index.ts b/frontend/src/components/routes/index.ts new file mode 100644 index 0000000..81a8fd2 --- /dev/null +++ b/frontend/src/components/routes/index.ts @@ -0,0 +1,2 @@ +export * from "./protected.route" +export * from "./public.route" diff --git a/frontend/src/components/routes/protected.route.tsx b/frontend/src/components/routes/protected.route.tsx new file mode 100644 index 0000000..2e79c6c --- /dev/null +++ b/frontend/src/components/routes/protected.route.tsx @@ -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
+ +
+ } + + if (!isAuthenticated) { + return + } + + return +} diff --git a/frontend/src/components/routes/public.route.tsx b/frontend/src/components/routes/public.route.tsx new file mode 100644 index 0000000..7d2da13 --- /dev/null +++ b/frontend/src/components/routes/public.route.tsx @@ -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
+ +
+ + if (isAuthenticated) { + return + } + + return +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..21409a0 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -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 & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..eaed9ba --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -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) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/frontend/src/components/ui/field.tsx b/frontend/src/components/ui/field.tsx new file mode 100644 index 0000000..db0dc12 --- /dev/null +++ b/frontend/src/components/ui/field.tsx @@ -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 ( +
[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 ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[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) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +