diff --git a/.dockerignore b/.dockerignore index 37c14e6..ab64301 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,13 +1,10 @@ -__pycache__ -*.pyc -*.pyo -*.pyd -.Python -env/ -venv/ -.env +node_modules +.next .git -.gitignore -.vscode -*.md +.github *.log +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/.env.example b/.env.example index 360dbd6..3d9d7fe 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ -MONGO_URI=mongodb://localhost:27017/uLinkShortener +MONGO_URI=mongodb://:@:/ +MONGO_DB_NAME= +NEXTAUTH_SECRET=VERY_SECURE_SECRET +NEXTAUTH_URL=http://localhost:3000 \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..89184f4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +version: 2 +updates: + # Monitor npm dependencies + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + versioning-strategy: auto + labels: + - "dependencies" + - "npm" + + # Monitor Docker dependencies + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "docker" + + # Monitor GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ca75b19 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,115 @@ +name: Build and Deploy + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] + release: + types: [created] + +jobs: + # Update lockfile for Dependabot PRs + update-lockfile: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'dependabot/') + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + - uses: oven-sh/setup-bun@v2 + - name: Install and update lockfile + run: bun install + - name: Commit and push updated lockfile + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add bun.lock + git commit -m "chore: update lockfile (auto-fix for Dependabot PR)" || exit 0 + git push + + # Build and push Docker image for Dependabot PRs + build_dependabot: + runs-on: ubuntu-latest + needs: update-lockfile + if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'dependabot/') + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest,enable={{is_default_branch}} + type=sha,format=short + type=ref,event=branch + type=ref,event=pr + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + platforms: linux/amd64,linux/arm64/v8 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + # Build and push Docker image for all other events + build: + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || (github.event_name == 'pull_request' && !startsWith(github.head_ref, 'dependabot/')) + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest,enable={{is_default_branch}} + type=sha,format=short + type=ref,event=branch + type=ref,event=pr + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name != 'pull_request' }} + platforms: linux/amd64,linux/arm64/v8 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 2a6e558..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Build on Release - -on: - release: - types: [created] - -jobs: - build: - runs-on: ubuntu-latest - - permissions: - contents: read - packages: write - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push Docker image - uses: docker/build-push-action@v2 - with: - context: . - push: true - tags: ghcr.io/marcus7i/ulinkshortener:latest \ No newline at end of file diff --git a/.gitignore b/.gitignore index d558a03..3431edd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,44 @@ -venv/ -.env \ No newline at end of file +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# logs +*.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 86a05c1..23edfb4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,22 @@ -FROM python:3.11-slim +FROM oven/bun:1 AS builder WORKDIR /app - -# Copy requirements first to leverage Docker cache -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile COPY . . -# Set environment variables -ENV FLASK_APP=server.py -ENV FLASK_ENV=production +RUN bun run build -# Expose port -EXPOSE 5000 +FROM oven/bun:1-slim AS runner -# Run the application -CMD ["python", "server.py"] +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/package.json ./package.json + +EXPOSE 3000 + +CMD ["bunx", "next", "start"] diff --git a/README.md b/README.md index 06d5ae8..a7d76a4 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,43 @@ -# uLinkShortener +# µLinkShortener v2 -This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a custom URL shortener. It uses Flask, MongoDB, and Docker for quick deployment. +This project is the code behind [u.kizuren.dev](https://u.kizuren.dev), a custom URL shortener. It uses Next.JS, MongoDB, and Docker for quick deployment. ## Prerequisites -- Python -- MongoDB database (local or remote) -- Docker & Docker Compose (optional, for containerized deployments) +- bun (optional, for development) +- Docker & Docker Compose ## Setup 1. Clone the repository -2. Create a virtual environment (optional): - ``` - python -m venv env - source env/bin/activate # Linux/Mac - env\Scripts\activate # Windows - ``` -3. Install dependencies: - ``` - pip install -r requirements.txt - ``` -4. Define environment variables in the `.env` file: +4. Define environment variables in the `.env` file (mongo connection string is not needed when using docker): ``` MONGO_URI=mongodb://:@:/ + MONGO_DB_NAME= + NEXTAUTH_SECRET=VERY_SECURE_SECRET + NEXTAUTH_URL=http://localhost:3000 ``` ## Running Locally -1. Start MongoDB -2. Run: - ``` - python server.py - ``` -3. Access the app at http://localhost:5000 -## Docker Deployment -1. Build and run containers: - ``` - docker-compose up --build - ``` -2. The application will be available at http://localhost:5000 +### Without Docker -## Using GHCR +1. Install dependencies: + ``` + bun i + ``` +2. Build and run: + ``` + bun run build + bun run start + ``` -Pull the prebuilt image: - ```bash - docker pull ghcr.io/MarcUs7i/ulinkshortener:latest - ``` \ No newline at end of file +### With Docker + + ``` + docker compose up -d + docker compose up --build + ``` +The application will be available at http://localhost:3000 + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..d918415 --- /dev/null +++ b/biome.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": [ + "**", + "!.next", + "!.log", + "next-env.d.ts", + "!node_modules", + "!dist", + "!build", + "!coverage", + "!*.min.js", + "!.env*", + "!*.log", + "!tmp", + "!temp", + "!*.config.js" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noExtraBooleanCast": "error", + "noUselessCatch": "error", + "noUselessConstructor": "error", + "noUselessLabel": "error", + "noUselessRename": "error" + }, + "correctness": { + "noConstAssign": "error", + "noConstantCondition": "error", + "noEmptyPattern": "error", + "noGlobalObjectCalls": "error", + "noInnerDeclarations": "error", + "noInvalidConstructorSuper": "error", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnusedLabels": "error", + "noUnusedVariables": "warn", + "useIsNan": "error" + }, + "style": { + "useConst": "warn", + "useTemplate": "warn" + }, + "suspicious": { + "noAsyncPromiseExecutor": "error", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCommentText": "error", + "noCompareNegZero": "error", + "noDebugger": "warn", + "noDoubleEquals": "warn", + "noDuplicateCase": "error", + "noDuplicateClassMembers": "error", + "noDuplicateObjectKeys": "error", + "noEmptyBlockStatements": "warn", + "noExplicitAny": "warn", + "noFallthroughSwitchClause": "error", + "noGlobalAssign": "error", + "noRedeclare": "error", + "noShadowRestrictedNames": "error", + "noUnsafeNegation": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "single", + "semicolons": "always", + "trailingCommas": "es5", + "arrowParentheses": "asNeeded" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "css": { + "parser": { + "cssModules": true, + "tailwindDirectives": true + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..68130d7 --- /dev/null +++ b/bun.lock @@ -0,0 +1,387 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "ulinkshortener", + "dependencies": { + "chart.js": "^4.5.1", + "mongodb": "^7.0.0", + "next": "16.0.8", + "next-auth": "^4.24.13", + "react": "^19.2.1", + "react-chartjs-2": "^5.3.1", + "react-dom": "^19.2.1", + "ua-parser-js": "^2.0.6", + "uuid": "^13.0.0", + "winston": "^3.19.0", + }, + "devDependencies": { + "@biomejs/biome": "^2.3.8", + "@tailwindcss/postcss": "^4.1.17", + "@types/node": "^24.10.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@types/uuid": "^11.0.0", + "tailwindcss": "^4.1.17", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="], + + "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="], + + "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], + + "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="], + + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.3" }, "os": "darwin", "cpu": "x64" }, "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.3" }, "os": "linux", "cpu": "arm" }, "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.3" }, "os": "linux", "cpu": "s390x" }, "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.4", "", { "dependencies": { "@emnapi/runtime": "^1.5.0" }, "cpu": "none" }, "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.4", "", { "os": "win32", "cpu": "x64" }, "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], + + "@jridgewell/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/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + + "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.3.0", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ=="], + + "@next/env": ["@next/env@16.0.8", "", {}, "sha512-xP4WrQZuj9MdmLJy3eWFHepo+R3vznsMSS8Dy3wdA7FKpjCiesQ6DxZvdGziQisj0tEtCgBKJzjcAc4yZOgLEQ=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yjVMvTQN21ZHOclQnhSFbjBTEizle+1uo4NV6L4rtS9WO3nfjaeJYw+H91G+nEf3Ef43TaEZvY5mPWfB/De7tA=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-+zu2N3QQ0ZOb6RyqQKfcu/pn0UPGmg+mUDqpAAEviAcEVEYgDckemOpiMRsBP3IsEKpcoKuNzekDcPczEeEIzA=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-LConttk+BeD0e6RG0jGEP9GfvdaBVMYsLJ5aDDweKiJVVCu6sGvo+Ohz9nQhvj7EQDVVRJMCGhl19DmJwGr6bQ=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-JaXFAlqn8fJV+GhhA9lpg6da/NCN/v9ub98n3HoayoUSPOVdoxEEt86iT58jXqQCs/R3dv5ZnxGkW8aF4obMrQ=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-O7M9it6HyNhsJp3HNAsJoHk5BUsfj7hRshfptpGcVsPZ1u0KQ/oVy8oxF7tlwxA5tR43VUP0yRmAGm1us514ng=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-8+KClEC/GLI2dLYcrWwHu5JyC5cZYCFnccVIvmxpo6K+XQt4qzqM5L4coofNDZYkct/VCCyJWGbZZDsg6w6LFA=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-rpQ/PgTEgH68SiXmhu/cJ2hk9aZ6YgFvspzQWe2I9HufY6g7V02DXRr/xrVqOaKm2lenBFPNQ+KAaeveywqV+A=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.8", "", { "os": "win32", "cpu": "x64" }, "sha512-jWpWjWcMQu2iZz4pEK2IktcfR+OA9+cCG8zenyLpcW8rN4rzjfOzH4yj/b1FiEAZHKS+5Vq8+bZyHi+2yqHbFA=="], + + "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + + "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], + + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "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.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], + + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], + + "@types/node": ["@types/node@24.10.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ=="], + + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + + "@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="], + + "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], + + "@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "bson": ["bson@7.0.0", "", {}, "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], + + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], + + "color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], + + "color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="], + + "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "detect-europe-js": ["detect-europe-js@0.1.2", "", {}, "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + + "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], + + "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], + + "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], + + "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=="], + + "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="], + + "mongodb": ["mongodb@7.0.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.0.0", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg=="], + + "mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.0", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og=="], + + "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": ["next@16.0.8", "", { "dependencies": { "@next/env": "16.0.8", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.8", "@next/swc-darwin-x64": "16.0.8", "@next/swc-linux-arm64-gnu": "16.0.8", "@next/swc-linux-arm64-musl": "16.0.8", "@next/swc-linux-x64-gnu": "16.0.8", "@next/swc-linux-x64-musl": "16.0.8", "@next/swc-win32-arm64-msvc": "16.0.8", "@next/swc-win32-x64-msvc": "16.0.8", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-LmcZzG04JuzNXi48s5P+TnJBsTGPJunViNKV/iE4uM6kstjTQsQhvsAv+xF6MJxU2Pr26tl15eVbp0jQnsv6/g=="], + + "next-auth": ["next-auth@4.24.13", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^8.3.2" }, "peerDependencies": { "@auth/core": "0.34.3", "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", "nodemailer": "^7.0.7", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ=="], + + "oauth": ["oauth@0.9.15", "", {}, "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="], + + "object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="], + + "oidc-token-hash": ["oidc-token-hash@5.1.0", "", {}, "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA=="], + + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], + + "openid-client": ["openid-client@5.7.1", "", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.5", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg=="], + + "preact": ["preact@10.26.9", "", {}, "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA=="], + + "preact-render-to-string": ["preact-render-to-string@5.2.6", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw=="], + + "pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="], + + "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="], + + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + + "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + + "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], + + "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=="], + + "ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="], + + "ua-parser-js": ["ua-parser-js@2.0.7", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-CFdHVHr+6YfbktNZegH3qbYvYgC7nRNEUm2tk7nSFXSODUu4tDBpaFpP1jdXBUOKKwapVlWRfTtS8bCPzsQ47w=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + + "winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="], + + "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@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.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="], + + "@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=="], + + "@types/uuid/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "next-auth/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..49f29f1 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,19 @@ +services: + mongo: + image: mongo:latest + container_name: ulinkshortener-mongo-dev + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: justauser + MONGO_INITDB_ROOT_PASSWORD: veryimportantpasswd + MONGO_INITDB_DATABASE: ulinkshortener + command: mongod --bind_ip_all + volumes: + - mongo_data:/data/db + - mongo_config:/data/configdb + ports: + - "27017:27017" + +volumes: + mongo_data: + mongo_config: diff --git a/docker-compose.yml b/docker-compose.yml index 6568b0c..c8e673d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,33 +1,33 @@ -version: '3.8' - services: - web: - build: . + ulinkshortener: + build: + context: . + dockerfile: Dockerfile + image: ghcr.io/kizuren/ulinkshortener ports: - - "5000:5000" + - "3000:3000" environment: - - MONGO_URI=mongodb://mongo:27017/uLinkShortener - depends_on: - - mongo + - MONGO_URI=mongodb://justauser:veryimportantpasswd@mongo:27017/ulinkshortener?authSource=admin + - MONGO_DB_NAME=ulinkshortener + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - NEXTAUTH_URL=${NEXTAUTH_URL} + env_file: + - .env restart: unless-stopped - networks: - - app-network mongo: image: mongo:latest - volumes: - - mongodb_data:/data/db - ports: - - "27017:27017" - networks: - - app-network + container_name: ulinkshortener-mongo + restart: always environment: - - MONGO_INITDB_DATABASE=uLinkShortener - restart: unless-stopped + MONGO_INITDB_ROOT_USERNAME: justauser + MONGO_INITDB_ROOT_PASSWORD: veryimportantpasswd + MONGO_INITDB_DATABASE: ulinkshortener + command: mongod --bind_ip_all + volumes: + - mongo_data:/data/db + - mongo_config:/data/configdb volumes: - mongodb_data: - -networks: - app-network: - driver: bridge + mongo_data: + mongo_config: diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..ec706a5 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + allowedDevOrigins: ['localhost', '*.marcus7i.net'], +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..44fd732 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "ulinkshortener", + "version": "2.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "biome lint", + "lint:fix": "biome check --write .", + "format": "biome format . --write" + }, + "dependencies": { + "chart.js": "^4.5.1", + "mongodb": "^7.0.0", + "next": "16.0.8", + "next-auth": "^4.24.13", + "react": "^19.2.3", + "react-chartjs-2": "^5.3.1", + "react-dom": "^19.2.3", + "ua-parser-js": "^2.0.7", + "uuid": "^13.0.0", + "winston": "^3.19.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.8", + "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^24.10.3", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@types/uuid": "^11.0.0", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..ba720fe --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ['@tailwindcss/postcss'], +}; + +export default config; diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index ed16465..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -flask -flask-pymongo -python-dotenv -requests \ No newline at end of file diff --git a/server.py b/server.py deleted file mode 100644 index 94d7583..0000000 --- a/server.py +++ /dev/null @@ -1,176 +0,0 @@ -from flask import Flask, request, redirect, render_template, jsonify, make_response -from flask_pymongo import PyMongo -from datetime import datetime -import random -import string -import os -from dotenv import load_dotenv -import json -from urllib.parse import quote_plus, urlparse - -load_dotenv() - -app = Flask(__name__, template_folder='template', static_folder='static', static_url_path='/static') -app.config["MONGO_URI"] = os.getenv("MONGO_URI") -mongo = PyMongo(app) - -def generate_short_id(): - return ''.join(random.choices(string.ascii_letters + string.digits, k=8)) - -def get_client_info(): - user_agent = request.user_agent - return { - 'ip': request.remote_addr or 'Unknown', - 'user_agent': user_agent.string, - 'platform': request.headers.get('sec-ch-ua-platform', user_agent.platform or 'Unknown'), - 'browser': user_agent.browser or 'Unknown', - 'version': user_agent.version or '', - 'language': request.accept_languages.best or 'Unknown', - 'referrer': request.referrer or 'Direct', - 'timestamp': datetime.now(), - 'remote_port': request.environ.get('REMOTE_PORT', 'Unknown'), - 'accept': request.headers.get('Accept', 'Unknown'), - 'accept_language': request.headers.get('Accept-Language', 'Unknown'), - 'accept_encoding': request.headers.get('Accept-Encoding', 'Unknown'), - 'screen_size': request.headers.get('Sec-CH-UA-Platform-Screen', 'Unknown'), - 'window_size': request.headers.get('Viewport-Width', 'Unknown'), - 'country': request.headers.get('CF-IPCountry', 'Unknown'), # If using Cloudflare - 'isp': request.headers.get('X-ISP', 'Unknown'), # Requires additional middleware - 'ip_version': 'IPv6' if ':' in request.remote_addr else 'IPv4' - } - -def is_valid_url(url): - if not url or url.isspace(): - return False - try: - result = urlparse(url) - return all([result.scheme, result.netloc]) - except: - return False - -@app.route('/') -def home(): - account_id = request.cookies.get('account_id') - - stats = { - 'total_links': mongo.db.links.count_documents({}), - 'total_clicks': mongo.db.analytics.count_documents({}), - 'chart_data': { - 'ip_versions': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$ip_version", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}} - ])), - 'os_stats': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$platform", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}}, - {"$limit": 10} - ])), - 'country_stats': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$country", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}}, - {"$limit": 10} - ])), - 'isp_stats': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$isp", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}}, - {"$limit": 10} - ])) - }, - 'logged_in': bool(account_id) - } - return render_template('index.html', stats=stats) - -@app.route('/register', methods=['POST']) -def register(): - account_id = ''.join(random.choices(string.digits, k=8)) - while mongo.db.users.find_one({'account_id': account_id}): - account_id = ''.join(random.choices(string.digits, k=8)) - - mongo.db.users.insert_one({'account_id': account_id}) - response = make_response(jsonify({'account_id': account_id})) - response.set_cookie('account_id', account_id, max_age=31536000) - return response - -@app.route('/login', methods=['POST']) -def login(): - account_id = request.json.get('account_id') - user = mongo.db.users.find_one({'account_id': account_id}) - if user: - response = make_response(jsonify({'success': True})) - response.set_cookie('account_id', account_id, max_age=31536000) - return response - return jsonify({'success': False}), 401 - -@app.route('/logout', methods=['POST']) -def logout(): - response = make_response(jsonify({'success': True})) - response.delete_cookie('account_id') - return response - -@app.route('/create', methods=['POST']) -def create_link(): - account_id = request.json.get('account_id') - target_url = request.json.get('url') - - if not mongo.db.users.find_one({'account_id': account_id}): - return jsonify({'error': 'Invalid account'}), 401 - - if not is_valid_url(target_url): - return jsonify({'error': 'Invalid URL. Please provide a valid URL with scheme (e.g., http:// or https://)'}), 400 - - short_id = generate_short_id() - mongo.db.links.insert_one({ - 'short_id': short_id, - 'target_url': target_url, - 'account_id': account_id, - 'created_at': datetime.now() - }) - - return jsonify({'short_url': f'/l/{short_id}'}) - -@app.route('/l/') -def redirect_link(short_id): - link = mongo.db.links.find_one({'short_id': short_id}) - if not link: - return 'Link not found', 404 - - client_info = get_client_info() - mongo.db.analytics.insert_one({ - 'link_id': short_id, - 'account_id': link['account_id'], - **client_info - }) - - return redirect(link['target_url']) - -@app.route('/analytics/') -def get_analytics(account_id): - if not mongo.db.users.find_one({'account_id': account_id}): - return jsonify({'error': 'Invalid account'}), 401 - - links = list(mongo.db.links.find({'account_id': account_id}, {'_id': 0})) - analytics = list(mongo.db.analytics.find({'account_id': account_id}, {'_id': 0})) - - return jsonify({ - 'links': links, - 'analytics': analytics - }) - -@app.route('/delete/', methods=['DELETE']) -def delete_link(short_id): - account_id = request.cookies.get('account_id') - if not account_id: - return jsonify({'error': 'Not logged in'}), 401 - - link = mongo.db.links.find_one({'short_id': short_id, 'account_id': account_id}) - if not link: - return jsonify({'error': 'Link not found or unauthorized'}), 404 - - # Delete the link and its analytics - mongo.db.links.delete_one({'short_id': short_id}) - mongo.db.analytics.delete_many({'link_id': short_id}) - - return jsonify({'success': True}) - -if __name__ == '__main__': - app.run(debug=True, host="0.0.0.0", port=5000) diff --git a/src/app/Home.module.css b/src/app/Home.module.css new file mode 100644 index 0000000..ab12e6a --- /dev/null +++ b/src/app/Home.module.css @@ -0,0 +1,172 @@ +/* Homepage specific styles */ +.default-container { + width: 100%; + max-width: 90%; + margin: 0 auto; + padding: 0 1.5rem; +} +@media (min-width: 768px) { + .default-container { + max-width: 80%; + } +} +@media (min-width: 1200px) { + .default-container { + max-width: 60%; + } +} + +/* Hero section */ +.hero-section { + min-height: 25vh; + display: flex; + flex-direction: column; + justify-content: center; + padding: 3rem 0 2rem; +} + +.hero-title { + font-size: 3.5rem; + font-weight: 700; + margin-bottom: 1.5rem; + background: linear-gradient(90deg, var(--accent), #9089fc); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero-description { + font-size: 1.25rem; + line-height: 1.7; + color: var(--text-secondary); + margin-bottom: 2rem; +} + +.hero-cta { + display: inline-block; + background-color: var(--accent); + color: white; + padding: 0.75rem 1.5rem; + border-radius: 0.375rem; + font-weight: 500; + text-decoration: none; + transition: background-color 0.2s; +} + +.hero-cta:hover { + background-color: var(--accent-hover); +} + +/* Stats section */ +.stats-summary { + margin-bottom: 2rem; + padding: 0 1rem; +} + +.stats-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 2rem; + margin: 0 auto; +} + +.stats-card { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: var(--card-shadow); + border: 1px solid var(--border-color); + transition: + transform 0.2s, + box-shadow 0.2s; + text-align: center; +} + +.stats-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2); +} + +.stats-card h3 { + font-size: 1rem; + margin-top: 0; + margin-bottom: 0.5rem; + color: var(--text-secondary); +} + +.stats-number { + font-size: 2.5rem; + font-weight: 700; + margin: 0; + color: var(--primary-color); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .stats-number { + font-size: 2rem; + } +} + +/* Graphs section */ +.graphs-section { + padding: 2rem 0 4rem; +} + +.graphs-title { + font-size: 2rem; + margin-bottom: 2rem; + text-align: center; +} + +.graphs-container { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 2rem; +} + +.graph-card { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color); + transition: + transform 0.2s, + box-shadow 0.2s; + height: 20rem; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.graph-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2); +} + +.graph-title { + font-size: 1.25rem; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.graph-content { + flex: 1; + position: relative; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .graphs-container { + grid-template-columns: 1fr; + } + + .hero-title { + font-size: 2.5rem; + } + + .default-container { + max-width: max-content; + } +} diff --git a/src/app/admin/AdminDashboard.module.css b/src/app/admin/AdminDashboard.module.css new file mode 100644 index 0000000..9b46ee7 --- /dev/null +++ b/src/app/admin/AdminDashboard.module.css @@ -0,0 +1,234 @@ +.adminContainer { + max-width: 75rem; + width: 100%; + margin: 0 auto; + padding: 2rem 1rem; +} + +.adminHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.adminTitle { + font-size: 2rem; + color: var(--text-primary); + margin: 0; +} + +.actionButtons { + display: flex; + gap: 1rem; +} + +.dashboardButton, +.statsButton { + padding: 0.5rem 1rem; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.dashboardButton { + background-color: var(--accent); + color: white; +} + +.statsButton { + background-color: var(--success); + color: white; +} + +.dashboardButton:hover, +.statsButton:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.statsButton:disabled { + background-color: var(--disabled-bg); + color: var(--disabled-text); + cursor: not-allowed; + transform: none; +} + +.usersSection { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + border: 1px solid var(--border-color); +} + +.sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.searchContainer { + position: relative; + display: flex; + align-items: center; +} + +.searchInput { + padding: 0.5rem 0.75rem; + padding-right: 2rem; + border-radius: 4px; + border: 1px solid var(--border-color); + background-color: var(--input-bg); + color: var(--text-primary); + font-size: 0.9rem; + width: 250px; +} + +.clearSearchButton { + position: absolute; + right: 0.5rem; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.8rem; +} + +.tableContainer { + overflow-x: auto; +} + +.usersTable { + width: 100%; + border-collapse: collapse; + margin-top: 0.5rem; +} + +.usersTable th, +.usersTable td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.usersTable th { + background-color: var(--table-header-bg); + color: var(--text-primary); + font-weight: 500; +} + +.usersTable tr:last-child td { + border-bottom: none; +} + +.usersTable tr:hover td { + background-color: var(--hover-bg); +} + +.actions { + display: flex; + gap: 0.5rem; +} + +.viewButton, +.deleteButton { + padding: 0.4rem 0.6rem; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.viewButton { + background-color: var(--accent); + color: white; +} + +.deleteButton { + background-color: var(--error); + color: white; +} + +.viewButton:hover, +.deleteButton:hover { + opacity: 0.9; +} + +.noResults { + text-align: center; + padding: 2rem; + color: var(--text-secondary); + font-style: italic; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + color: var(--text-secondary); + font-size: 1rem; +} + +.makeAdminButton, +.removeAdminButton { + padding: 0.4rem 0.6rem; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; + margin-right: 0.5rem; +} + +.makeAdminButton { + background-color: var(--success); + color: white; +} + +.removeAdminButton { + background-color: var(--warning); + color: white; +} + +.makeAdminButton:hover, +.removeAdminButton:hover { + opacity: 0.9; +} + +@media (max-width: 768px) { + .adminHeader { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .actionButtons { + width: 100%; + } + + .sectionHeader { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .searchContainer { + width: 100%; + } + + .searchInput { + width: 100%; + } + + .actions { + flex-direction: column; + } +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..3f40b98 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,277 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import { useToast } from '@/contexts/ToastContext'; +import styles from './AdminDashboard.module.css'; +import type { User } from '@/types/user'; + +export default function AdminDashboard() { + const { data: session, status } = useSession(); + const router = useRouter(); + const { showToast } = useToast(); + + const [users, setUsers] = useState([]); + const [filteredUsers, setFilteredUsers] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [loading, setLoading] = useState(true); + const [userToDelete, setUserToDelete] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRecreatingStats, setIsRecreatingStats] = useState(false); + + useEffect(() => { + if (status === 'unauthenticated' || (status === 'authenticated' && !session?.user?.isAdmin)) { + router.push('/dashboard'); + } + }, [status, session, router]); + + useEffect(() => { + if (session?.user?.isAdmin) { + fetchUsers(); + } + }, [session]); + + useEffect(() => { + if (!searchTerm.trim()) { + setFilteredUsers(users); + return; + } + + const term = searchTerm.toLowerCase(); + const filtered = users.filter( + user => + user.account_id.toLowerCase().includes(term) || + user.created_at.toLocaleString().toLowerCase().includes(term) || + (user.is_admin ? 'admin' : 'user').includes(term) + ); + + setFilteredUsers(filtered); + }, [searchTerm, users]); + + const fetchUsers = async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/users'); + + if (!response.ok) { + throw new Error('Failed to fetch users'); + } + + const data = await response.json(); + + if (data.success) { + const processedUsers = data.users.map((user: User) => ({ + account_id: user.account_id, + is_admin: user.is_admin, + created_at: new Date(user.created_at), + })); + + setUsers(processedUsers); + setFilteredUsers(processedUsers); + } else { + showToast(data.message || 'Failed to load users', 'error'); + } + } catch (error) { + console.error('Error fetching users:', error); + showToast('Failed to load users', 'error'); + } finally { + setLoading(false); + } + }; + + const handleDeleteUser = async () => { + if (!userToDelete) return; + + try { + const response = await fetch('/api/admin/users', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ account_id: userToDelete }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('User deleted successfully', 'success'); + setUsers(prevUsers => prevUsers.filter(user => user.account_id !== userToDelete)); + } else { + showToast(data.message || 'Failed to delete user', 'error'); + } + } catch (error) { + console.error('Error deleting user:', error); + showToast('Failed to delete user', 'error'); + } finally { + setUserToDelete(null); + setIsDeleteModalOpen(false); + } + }; + + const confirmDeleteUser = (accountId: string) => { + setUserToDelete(accountId); + setIsDeleteModalOpen(true); + }; + + const handleRecreateStatistics = async () => { + try { + setIsRecreatingStats(true); + const response = await fetch('/api/admin/statistics/rebuild', { + method: 'POST', + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('Statistics recreated successfully', 'success'); + } else { + showToast(data.message || 'Failed to recreate statistics', 'error'); + } + } catch (error) { + console.error('Error recreating statistics:', error); + showToast('Failed to recreate statistics', 'error'); + } finally { + setIsRecreatingStats(false); + } + }; + + const handleToggleAdminStatus = async (accountId: string) => { + try { + const response = await fetch(`/api/admin/users/${accountId}/admin`, { + method: 'POST', + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast(data.message, 'success'); + setUsers(prevUsers => + prevUsers.map(user => + user.account_id === accountId ? { ...user, is_admin: data.is_admin } : user + ) + ); + } else { + showToast(data.message || 'Failed to toggle admin status', 'error'); + } + } catch (error) { + console.error('Error toggling admin status:', error); + showToast('Failed to toggle admin status', 'error'); + } + }; + + if (status === 'loading' || loading) { + return
Loading...
; + } + + if (status === 'unauthenticated' || (status === 'authenticated' && !session?.user?.isAdmin)) { + return null; + } + + return ( +
+
+

Admin Dashboard

+
+ + + + +
+
+ +
+
+

Manage Users

+
+ setSearchTerm(e.target.value)} + className={styles.searchInput} + /> + {searchTerm && ( + + )} +
+
+ + {filteredUsers.length === 0 ? ( +
+ {searchTerm ? 'No users match your search' : 'No users available'} +
+ ) : ( +
+ + + + + + + + + + + {filteredUsers.map(user => ( + + + + + + + ))} + +
Account IDCreatedRoleActions
{user.account_id}{user.created_at.toLocaleString()}{user.is_admin ? 'Admin' : 'User'} + + + + + {user.account_id !== session?.user?.accountId && ( + + )} + + {!user.is_admin && ( + + )} +
+
+ )} +
+ + setIsDeleteModalOpen(false)} + /> +
+ ); +} diff --git a/src/app/admin/user/[accountId]/UserDetail.module.css b/src/app/admin/user/[accountId]/UserDetail.module.css new file mode 100644 index 0000000..a2cc99a --- /dev/null +++ b/src/app/admin/user/[accountId]/UserDetail.module.css @@ -0,0 +1,178 @@ +.container { + max-width: 75rem; + width: 100%; + margin: 0 auto; + padding: 2rem 1rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.title { + font-size: 2rem; + color: var(--text-primary); + margin: 0; +} + +.actionButtons { + display: flex; + gap: 1rem; +} + +.backButton, +.deleteButton { + padding: 0.5rem 1rem; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.backButton { + background-color: var(--accent); + color: white; +} + +.deleteButton { + background-color: var(--error); + color: white; +} + +.backButton:hover, +.deleteButton:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.userInfo, +.linksSection, +.sessionsSection { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + border: 1px solid var(--border-color); +} + +.infoCard { + background-color: var(--card-secondary-bg); + border-radius: 6px; + padding: 1rem; + margin-top: 1rem; +} + +.infoRow { + display: flex; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); +} + +.infoRow:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.infoLabel { + width: 120px; + font-weight: 500; + color: var(--text-secondary); +} + +.infoValue { + flex: 1; + color: var(--text-primary); +} + +.noItems { + text-align: center; + padding: 2rem; + color: var(--text-secondary); + font-style: italic; +} + +.tableContainer { + overflow-x: auto; +} + +.sessionsTable { + width: 100%; + border-collapse: collapse; + margin-top: 0.5rem; +} + +.sessionsTable th, +.sessionsTable td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.sessionsTable th { + background-color: var(--table-header-bg); + color: var(--text-primary); + font-weight: 500; +} + +.sessionsTable tr:last-child td { + border-bottom: none; +} + +.sessionsTable tr:hover td { + background-color: var(--hover-bg); +} + +.revokeButton { + padding: 0.4rem 0.6rem; + border-radius: 4px; + font-size: 0.8rem; + background-color: var(--error); + color: white; + border: none; + cursor: pointer; + transition: all 0.2s ease; +} + +.revokeButton:hover { + opacity: 0.9; +} + +.revokeButton:disabled { + background-color: var(--disabled-bg); + color: var(--disabled-text); + cursor: not-allowed; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + color: var(--text-secondary); + font-size: 1rem; +} + +.error { + text-align: center; + padding: 2rem; + color: var(--error); + font-size: 1rem; +} + +@media (max-width: 768px) { + .header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .actionButtons { + width: 100%; + } +} diff --git a/src/app/admin/user/[accountId]/links/[shortId]/AdminLinkDetail.module.css b/src/app/admin/user/[accountId]/links/[shortId]/AdminLinkDetail.module.css new file mode 100644 index 0000000..7d09e32 --- /dev/null +++ b/src/app/admin/user/[accountId]/links/[shortId]/AdminLinkDetail.module.css @@ -0,0 +1,344 @@ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.titleSection { + display: flex; + flex-direction: column; +} + +.title { + font-size: 2rem; + margin: 0; + color: var(--text-primary); +} + +.breadcrumbs { + font-size: 0.9rem; + color: var(--text-secondary); + margin-top: 0.5rem; +} + +.breadcrumbLink { + color: var(--accent); + text-decoration: none; + margin: 0 0.25rem; +} + +.breadcrumbLink:hover { + text-decoration: underline; +} + +.breadcrumbCurrent { + color: var(--text-primary); + margin: 0 0.25rem; +} + +.backLink { + padding: 0.5rem 1rem; + border-radius: 4px; + background-color: var(--accent); + color: var(--text-primary); + text-decoration: none; + font-weight: 500; + transition: all 0.2s ease; +} + +.backLink:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.userInfo { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1rem 1.5rem; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + border-left: 4px solid var(--accent); +} + +.label { + font-weight: 600; + color: var(--text-secondary); + margin-right: 0.5rem; +} + +.value { + color: var(--text-primary); + word-break: break-all; +} + +.linkInfo { + margin-bottom: 2rem; +} + +.linkCard { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: var(--card-shadow); +} + +.linkCard h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: var(--text-primary); + font-size: 1.5rem; +} + +.linkDetails { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.linkDetailItem { + display: flex; + flex-direction: column; +} + +.shortUrl { + color: var(--primary-color); + word-break: break-all; + text-decoration: none; +} + +.shortUrl:hover { + text-decoration: underline; +} + +.defaultButton { + background-color: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + cursor: pointer; +} + +.defaultButton:hover { + background-color: var(--primary-color); + color: white; +} + +.targetUrlSection { + margin-top: 0.5rem; +} + +.targetUrlHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.editButton { + background-color: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + cursor: pointer; +} + +.editButton:hover { + background-color: var(--primary-color); + color: white; +} + +.targetUrl { + color: var(--text-primary); + word-break: break-all; + text-decoration: none; +} + +.targetUrl:hover { + text-decoration: underline; + color: var(--primary-color); +} + +.editForm { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.urlInput { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + background-color: var(--input-bg); + color: var(--text-primary); +} + +.editActions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.cancelButton { + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.cancelButton:hover { + background-color: var(--border-color); +} + +.saveButton { + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.saveButton:hover { + background-color: var(--primary-dark); +} + +.analyticsSection { + margin-top: 2rem; +} + +.analyticsHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} + +.analyticsHeader h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.5rem; +} + +.totalClicks { + color: var(--text-secondary); + font-weight: 600; +} + +.deleteAllButton { + background-color: transparent; + color: var(--error); + border: 1px solid var(--error); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 1rem; + cursor: pointer; +} + +.deleteAllButton:hover { + background-color: var(--error); + color: white; +} + +.graphs { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.graphCard { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1rem; + box-shadow: var(--card-shadow); +} + +.graphCard h3 { + margin-top: 0; + margin-bottom: 1rem; + color: var(--text-primary); + font-size: 1rem; + text-align: center; +} + +.noAnalytics { + display: flex; + justify-content: center; + align-items: center; + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 2rem; + box-shadow: var(--card-shadow); +} + +.noAnalytics p { + color: var(--text-secondary); + font-size: 1rem; + text-align: center; +} + +.loadingContainer { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 300px; + gap: 1rem; +} + +.loader { + border: 4px solid rgba(0, 0, 0, 0.1); + border-left-color: var(--primary-color); + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; +} + +.error { + text-align: center; + color: var(--error); + font-size: 1.2rem; + margin-top: 2rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 768px) { + .header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .analyticsHeader { + flex-direction: column; + align-items: flex-start; + } + + .graphs { + grid-template-columns: 1fr; + } +} diff --git a/src/app/admin/user/[accountId]/links/[shortId]/page.tsx b/src/app/admin/user/[accountId]/links/[shortId]/page.tsx new file mode 100644 index 0000000..3ea1d4d --- /dev/null +++ b/src/app/admin/user/[accountId]/links/[shortId]/page.tsx @@ -0,0 +1,573 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useSession } from 'next-auth/react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useToast } from '@/contexts/ToastContext'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import AnalyticsTable from '@/components/ui/dashboard/link/AnalyticsTable'; +import Graph from '@/components/ui/Graph'; +import styles from './AdminLinkDetail.module.css'; +import type { Link as LinkType } from '@/types/link'; +import type { Analytics } from '@/types/analytics'; +import type { StatItem } from '@/types/statistics'; + +export default function AdminLinkDetailPage() { + const { data: session, status } = useSession(); + const params = useParams(); + const router = useRouter(); + const { showToast } = useToast(); + const accountId = params.accountId as string; + const shortId = params.shortId as string; + + const [link, setLink] = useState(null); + const [targetUrl, setTargetUrl] = useState(''); + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [allAnalytics, setAllAnalytics] = useState([]); + const [analytics, setAnalytics] = useState([]); + const [totalAnalytics, setTotalAnalytics] = useState(0); + const [page, setPage] = useState(1); + const [limit] = useState(15); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showDeleteAllModal, setShowDeleteAllModal] = useState(false); + const [analyticsToDelete, setAnalyticsToDelete] = useState(''); + const [isLoadingStats, setIsLoadingStats] = useState(true); + const isRedirecting = useRef(false); + + // Stats data + const [browserStats, setBrowserStats] = useState([]); + const [osStats, setOsStats] = useState([]); + const [countryStats, setCountryStats] = useState([]); + const [ipVersionStats, setIpVersionStats] = useState([]); + + useEffect(() => { + if (status === 'unauthenticated' || (status === 'authenticated' && !session?.user?.isAdmin)) { + router.push('/dashboard'); + } + }, [status, session, router]); + + function isValidUrl(urlStr: string): boolean { + if (urlStr.trim() === '') { + return false; + } + + try { + const parsedUrl = new URL(urlStr); + return parsedUrl.protocol !== '' && parsedUrl.hostname !== ''; + } catch { + return false; + } + } + + useEffect(() => { + if (isEditing && inputRef.current) { + setTimeout(() => { + inputRef.current?.focus(); + }, 10); + } + }, [isEditing]); + + useEffect(() => { + if (isRedirecting.current || status !== 'authenticated' || !session?.user?.isAdmin) return; + + async function fetchLinkData() { + try { + setIsLoading(true); + const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}`); + if (!response.ok) { + showToast('Failed to load link details', 'error'); + isRedirecting.current = true; + router.push(`/admin/user/${accountId}`); + return; + } + + const data = await response.json(); + if (data.success && data.link) { + setLink(data.link); + setTargetUrl(data.link.target_url); + } else { + showToast(data.message || 'Link not found', 'error'); + isRedirecting.current = true; + router.push(`/admin/user/${accountId}`); + } + } catch { + showToast('An error occurred while loading link details', 'error'); + isRedirecting.current = true; + router.push(`/admin/user/${accountId}`); + } finally { + setIsLoading(false); + } + } + + fetchLinkData(); + }, [shortId, accountId, router, showToast, session, status]); + + useEffect(() => { + if (!link || status !== 'authenticated' || !session?.user?.isAdmin) return; + + async function fetchAllAnalytics() { + try { + const response = await fetch( + `/api/admin/users/${accountId}/links/${shortId}/analytics?all=true` + ); + if (!response.ok) { + showToast('Failed to load complete analytics data', 'error'); + return; + } + + const data = await response.json(); + if (data.success) { + setAllAnalytics(data.analytics); + setTotalAnalytics(data.pagination.total); + } + } catch { + showToast('An error occurred while loading complete analytics data', 'error'); + } + } + + fetchAllAnalytics(); + }, [link, shortId, accountId, showToast, session, status]); + + useEffect(() => { + if (!link || status !== 'authenticated' || !session?.user?.isAdmin) return; + + async function fetchPaginatedAnalytics() { + try { + const response = await fetch( + `/api/admin/users/${accountId}/links/${shortId}/analytics?page=${page}&limit=${limit}` + ); + if (!response.ok) { + showToast('Failed to load analytics page', 'error'); + return; + } + + const data = await response.json(); + if (data.success) { + setAnalytics(data.analytics); + } + } catch { + showToast('An error occurred while loading analytics page', 'error'); + } + } + + fetchPaginatedAnalytics(); + }, [link, shortId, accountId, page, limit, showToast, session, status]); + + useEffect(() => { + if (!link || allAnalytics.length === 0) return; + + async function generateStats() { + setIsLoadingStats(true); + try { + // Browser stats + const browsers = allAnalytics.reduce((acc: Record, item) => { + const browser = item.browser || 'Unknown'; + acc[browser] = (acc[browser] || 0) + 1; + return acc; + }, {}); + + // OS stats + const oses = allAnalytics.reduce((acc: Record, item) => { + const os = item.platform || 'Unknown'; + acc[os] = (acc[os] || 0) + 1; + return acc; + }, {}); + + // Country stats + const countries = allAnalytics.reduce((acc: Record, item) => { + const country = item.country || 'Unknown'; + acc[country] = (acc[country] || 0) + 1; + return acc; + }, {}); + + // IP version stats + const ipVersions = allAnalytics.reduce((acc: Record, item) => { + const ipVersion = item.ip_version || 'Unknown'; + acc[ipVersion] = (acc[ipVersion] || 0) + 1; + return acc; + }, {}); + + // Convert to StatItem[] and sort by count + setBrowserStats( + Object.entries(browsers) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); + + setOsStats( + Object.entries(oses) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); + + setCountryStats( + Object.entries(countries) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); + + setIpVersionStats( + Object.entries(ipVersions) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); + } catch { + showToast('An error occurred while processing analytics data', 'error'); + } finally { + setIsLoadingStats(false); + } + } + + generateStats(); + }, [allAnalytics, link, showToast]); + + const handlePageChange = (newPage: number) => { + setPage(newPage); + }; + + const handleEditLink = async () => { + if (!isValidUrl(targetUrl)) { + showToast('Please enter a valid URL', 'error'); + return; + } + + try { + const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + target_url: targetUrl, + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('Link updated successfully', 'success'); + setIsEditing(false); + if (link) { + setLink({ + ...link, + target_url: targetUrl, + last_modified: new Date(), + }); + } + } else { + showToast(data.message || 'Failed to update link', 'error'); + } + } catch { + showToast('An error occurred while updating the link', 'error'); + } + }; + + const handleDeleteAnalytics = async () => { + try { + const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}/analytics`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + analytics_id: analyticsToDelete, + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('Analytics entry deleted successfully', 'success'); + + setAnalytics(analytics.filter(item => item._id?.toString() !== analyticsToDelete)); + + setTotalAnalytics(prev => prev - 1); + } else { + showToast(data.message || 'Failed to delete analytics entry', 'error'); + } + } catch { + showToast('An error occurred while deleting the analytics entry', 'error'); + } finally { + setShowDeleteModal(false); + setAnalyticsToDelete(''); + } + }; + + const handleDeleteAllAnalytics = async () => { + try { + const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}/analytics`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + delete_all: true, + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('All analytics entries deleted successfully', 'success'); + + setAnalytics([]); + setTotalAnalytics(0); + setBrowserStats([]); + setOsStats([]); + setCountryStats([]); + setIpVersionStats([]); + } else { + showToast(data.message || 'Failed to delete all analytics entries', 'error'); + } + } catch { + showToast('An error occurred while deleting all analytics entries', 'error'); + } finally { + setShowDeleteAllModal(false); + } + }; + + if (status === 'loading' || isLoading) { + return ( +
+
+

Loading link details...

+
+ ); + } + + if (status === 'unauthenticated' || (status === 'authenticated' && !session?.user?.isAdmin)) { + return null; + } + + if (!link) { + return ( +
+ Link not found or you don't have permission to view it. +
+ ); + } + + return ( +
+
+
+

Admin Link Management

+
+ + Admin + {' '} + > + + User + {' '} + > + Link {shortId} +
+
+ + Back to User + +
+ +
+ Managing link for User ID: + {accountId} +
+ +
+
+

Link Information

+
+
+ Short ID: + {shortId} +
+ +
+ + +
+ +
+ Created: + + {link.created_at instanceof Date + ? link.created_at.toLocaleString() + : new Date(link.created_at).toLocaleString()} + +
+ +
+ Last Modified: + + {link.last_modified instanceof Date + ? link.last_modified.toLocaleString() + : new Date(link.last_modified).toLocaleString()} + +
+ +
+
+ Target URL: + {!isEditing && ( + + )} +
+ + {isEditing ? ( +
{ + e.preventDefault(); + handleEditLink(); + }} + className={styles.editForm} + > + setTargetUrl(e.target.value)} + className={styles.urlInput} + placeholder='https://example.com' + /> +
+ + +
+
+ ) : ( + + {link.target_url} + + )} +
+
+
+
+ +
+
+

Analytics

+ Total Clicks: {totalAnalytics} + {totalAnalytics > 0 && ( + + )} +
+ + {totalAnalytics > 0 ? ( + <> +
+
+

Browsers

+ +
+ +
+

Operating Systems

+ +
+ +
+

Countries

+ +
+ +
+

IP Versions

+ +
+
+ + { + setAnalyticsToDelete(id); + setShowDeleteModal(true); + }} + /> + + ) : ( +
+

No clicks recorded yet for this link.

+
+ )} +
+ + {/* Confirm Delete Modal */} + { + setShowDeleteModal(false); + setAnalyticsToDelete(''); + }} + /> + + {/* Confirm Delete All Modal */} + setShowDeleteAllModal(false)} + /> +
+ ); +} diff --git a/src/app/admin/user/[accountId]/page.tsx b/src/app/admin/user/[accountId]/page.tsx new file mode 100644 index 0000000..7f65384 --- /dev/null +++ b/src/app/admin/user/[accountId]/page.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; +import Link from 'next/link'; +import AdminLinkTable from '@/components/ui/admin/AdminLinkTable'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import { useToast } from '@/contexts/ToastContext'; +import styles from './UserDetail.module.css'; +import type { User } from '@/types/user'; +import type { SessionInfo } from '@/types/session'; + +export default function UserDetailPage() { + const params = useParams(); + const router = useRouter(); + const { data: session, status } = useSession(); + const { showToast } = useToast(); + const accountId = params.accountId as string; + + const [user, setUser] = useState(null); + const [links, setLinks] = useState([]); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [revoking, setRevoking] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + useEffect(() => { + if (status === 'unauthenticated' || (status === 'authenticated' && !session?.user?.isAdmin)) { + router.push('/dashboard'); + } + }, [status, session, router]); + + useEffect(() => { + if (status === 'authenticated' && session?.user?.isAdmin) { + fetchUserData(); + } + }, [status, session, accountId]); + + const fetchUserData = async () => { + try { + setLoading(true); + const userResponse = await fetch(`/api/admin/users/${accountId}`); + + if (!userResponse.ok) { + throw new Error('Failed to fetch user details'); + } + + const userData = await userResponse.json(); + if (userData.success) { + setUser(userData.user); + + const linksResponse = await fetch(`/api/admin/users/${accountId}/links`); + const linksData = await linksResponse.json(); + + if (linksResponse.ok && linksData.success) { + setLinks(linksData.links); + } + + const sessionsResponse = await fetch(`/api/admin/users/${accountId}/sessions`); + const sessionsData = await sessionsResponse.json(); + + if (sessionsResponse.ok && sessionsData.success) { + setSessions(sessionsData.sessions); + } + } else { + showToast(userData.message || 'Failed to load user details', 'error'); + router.push('/admin'); + } + } catch (error) { + console.error('Error fetching user data:', error); + showToast('Failed to load user details', 'error'); + router.push('/admin'); + } finally { + setLoading(false); + } + }; + + const handleRevokeSession = async (sessionId: string) => { + try { + setRevoking(sessionId); + + const response = await fetch(`/api/admin/users/${accountId}/sessions/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, accountId }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('Session revoked successfully', 'success'); + setSessions(prevSessions => prevSessions.filter(s => s.id !== sessionId)); + } else { + showToast(data.message || 'Failed to revoke session', 'error'); + } + } catch (error) { + console.error('Error revoking session:', error); + showToast('Failed to revoke session', 'error'); + } finally { + setRevoking(null); + } + }; + + const handleDeleteUser = async () => { + try { + const response = await fetch('/api/admin/users', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ account_id: accountId }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('User deleted successfully', 'success'); + router.push('/admin'); + } else { + showToast(data.message || 'Failed to delete user', 'error'); + } + } catch (error) { + console.error('Error deleting user:', error); + showToast('Failed to delete user', 'error'); + } finally { + setIsDeleteModalOpen(false); + } + }; + + const formatDate = (dateString: Date) => { + const date = new Date(dateString); + return date.toLocaleString(); + }; + + if (status === 'loading' || loading) { + return
Loading user details...
; + } + + if (!user) { + return
User not found
; + } + + return ( +
+
+

User Details

+
+ + + + {!user.is_admin && ( + + )} +
+
+ +
+

Account Information

+
+
+ Account ID: + {user.account_id} +
+
+ Created: + {formatDate(user.created_at)} +
+
+ Role: + {user.is_admin ? 'Admin' : 'User'} +
+
+
+ +
+

User Links

+ {links.length === 0 ? ( +
This user has not created any links yet.
+ ) : ( + + )} +
+ +
+

Active Sessions

+ {sessions.length === 0 ? ( +
This user has no active sessions.
+ ) : ( +
+ + + + + + + + + + + + {sessions.map(s => ( + + + + + + + + ))} + +
Device & BrowserIP AddressLast ActiveCreatedActions
{s.userAgent.split(' ').slice(0, 3).join(' ')}{s.ipAddress}{formatDate(s.lastActive)}{formatDate(s.createdAt)} + +
+
+ )} +
+ + setIsDeleteModalOpen(false)} + /> +
+ ); +} diff --git a/src/app/api/admin/statistics/rebuild/route.ts b/src/app/api/admin/statistics/rebuild/route.ts new file mode 100644 index 0000000..34950e6 --- /dev/null +++ b/src/app/api/admin/statistics/rebuild/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { updateStats } from '@/lib/statisticsdb'; +import logger from '@/lib/logger'; + +export async function POST() { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const result = await updateStats(); + + return NextResponse.json({ + message: result.status, + success: result.success, + }); + } catch (error) { + logger.error('Error rebuilding statistics:', { error }); + return NextResponse.json( + { + message: 'Failed to rebuild statistics', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/users/[accountId]/admin/route.ts b/src/app/api/admin/users/[accountId]/admin/route.ts new file mode 100644 index 0000000..d3bd012 --- /dev/null +++ b/src/app/api/admin/users/[accountId]/admin/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { isUserAdmin, makeUserAdmin } from '@/lib/userdb'; +import { removeAllSessionsByAccountId } from '@/lib/sessiondb'; +import logger from '@/lib/logger'; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ accountId: string }> } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { accountId } = await params; + + if (!accountId) { + return NextResponse.json( + { + message: 'Account ID is required', + success: false, + }, + { status: 400 } + ); + } + + if (accountId === session.user.accountId) { + return NextResponse.json( + { + message: 'You cannot change your own admin status', + success: false, + }, + { status: 400 } + ); + } + + const isAdmin = await isUserAdmin(accountId); + const result = await makeUserAdmin(accountId, !isAdmin); + + if (!result.success) { + return NextResponse.json( + { + message: result.status, + success: false, + }, + { status: result.status === 'User not found' ? 404 : 500 } + ); + } + + await removeAllSessionsByAccountId(accountId); + + logger.info(`Admin status toggled for user ${accountId}, all sessions revoked`, { + accountId, + newStatus: !isAdmin, + }); + + return NextResponse.json({ + message: `${result.status} User will need to log in again.`, + is_admin: !isAdmin, + success: true, + }); + } catch (error) { + logger.error('Error toggling admin status:', error); + return NextResponse.json( + { + message: 'Failed to toggle admin status', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/users/[accountId]/links/[shortId]/analytics/route.ts b/src/app/api/admin/users/[accountId]/links/[shortId]/analytics/route.ts new file mode 100644 index 0000000..bc38cf2 --- /dev/null +++ b/src/app/api/admin/users/[accountId]/links/[shortId]/analytics/route.ts @@ -0,0 +1,147 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { getAllAnalytics, removeAnalytics, removeAllAnalytics } from '@/lib/analyticsdb'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ accountId: string; shortId: string }> } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { accountId, shortId } = await params; + + if (!accountId || !shortId) { + return NextResponse.json( + { + message: 'Account ID and Short ID are required', + success: false, + }, + { status: 400 } + ); + } + + const url = new URL(req.url); + const page = parseInt(url.searchParams.get('page') || '1'); + const limit = parseInt(url.searchParams.get('limit') || '50'); + + const { analytics, total } = await getAllAnalytics(accountId, shortId, { page, limit }); + + const sanitizedAnalytics = analytics.map(item => sanitizeMongoDocument(item)); + + return NextResponse.json({ + analytics: sanitizedAnalytics, + pagination: { + total, + page, + limit, + pages: Math.ceil(total / limit), + }, + success: true, + }); + } catch (error) { + logger.error('Error getting analytics:', { + error, + accountId: (await params).accountId, + shortId: (await params).shortId, + }); + return NextResponse.json( + { + message: 'Failed to retrieve analytics', + success: false, + }, + { status: 500 } + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ accountId: string; shortId: string }> } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { accountId, shortId } = await params; + + if (!accountId || !shortId) { + return NextResponse.json( + { + message: 'Account ID and Short ID are required', + success: false, + }, + { status: 400 } + ); + } + + const body = await req.json(); + + if (body.delete_all) { + const result = await removeAllAnalytics(accountId, shortId); + + return NextResponse.json( + { + message: result.status, + success: result.success, + }, + { status: result.success ? 200 : 400 } + ); + } + + if (body.analytics_id) { + const result = await removeAnalytics(accountId, shortId, body.analytics_id); + + return NextResponse.json( + { + message: result.status, + success: result.success, + }, + { status: result.success ? 200 : 400 } + ); + } + + return NextResponse.json( + { + message: 'Either delete_all or analytics_id must be provided', + success: false, + }, + { status: 400 } + ); + } catch (error) { + logger.error('Error deleting analytics:', { + error, + accountId: (await params).accountId, + shortId: (await params).shortId, + }); + return NextResponse.json( + { + message: 'Failed to delete analytics', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/users/[accountId]/links/[shortId]/route.ts b/src/app/api/admin/users/[accountId]/links/[shortId]/route.ts new file mode 100644 index 0000000..a144c5d --- /dev/null +++ b/src/app/api/admin/users/[accountId]/links/[shortId]/route.ts @@ -0,0 +1,188 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { getLinkById, editLink, removeLink } from '@/lib/linkdb'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ accountId: string; shortId: string }> } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { accountId, shortId } = await params; + + if (!accountId || !shortId) { + return NextResponse.json( + { + message: 'Account ID and Short ID are required', + success: false, + }, + { status: 400 } + ); + } + + const { link, return: linkReturn } = await getLinkById(accountId, shortId); + + if (!linkReturn.success || !link) { + return NextResponse.json( + { + message: linkReturn.status, + success: false, + }, + { status: 404 } + ); + } + + return NextResponse.json({ + link: sanitizeMongoDocument(link), + success: true, + }); + } catch (error) { + logger.error('Error getting link details:', { + error, + accountId: (await params).accountId, + shortId: (await params).shortId, + }); + return NextResponse.json( + { + message: 'Failed to retrieve link details', + success: false, + }, + { status: 500 } + ); + } +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ accountId: string; shortId: string }> } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { accountId, shortId } = await params; + + if (!accountId || !shortId) { + return NextResponse.json( + { + message: 'Account ID and Short ID are required', + success: false, + }, + { status: 400 } + ); + } + + const body = await req.json(); + const { target_url } = body; + + if (!target_url) { + return NextResponse.json( + { + message: 'Target URL is required', + success: false, + }, + { status: 400 } + ); + } + + const result = await editLink(accountId, shortId, target_url); + + return NextResponse.json( + { + message: result.status, + success: result.success, + }, + { status: result.success ? 200 : 400 } + ); + } catch (error) { + logger.error('Error updating link:', { + error, + accountId: (await params).accountId, + shortId: (await params).shortId, + }); + return NextResponse.json( + { + message: 'Failed to update link', + success: false, + }, + { status: 500 } + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ accountId: string; shortId: string }> } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { accountId, shortId } = await params; + + if (!accountId || !shortId) { + return NextResponse.json( + { + message: 'Account ID and Short ID are required', + success: false, + }, + { status: 400 } + ); + } + + const result = await removeLink(accountId, shortId); + + return NextResponse.json( + { + message: result.status, + success: result.success, + }, + { status: result.success ? 200 : 400 } + ); + } catch (error) { + logger.error('Error deleting link:', { + error, + accountId: (await params).accountId, + shortId: (await params).shortId, + }); + return NextResponse.json( + { + message: 'Failed to delete link', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/users/[accountId]/links/route.ts b/src/app/api/admin/users/[accountId]/links/route.ts new file mode 100644 index 0000000..7b9f924 --- /dev/null +++ b/src/app/api/admin/users/[accountId]/links/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { getLinks } from '@/lib/linkdb'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ accountId: string }> } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { accountId } = await params; + + if (!accountId) { + return NextResponse.json( + { + message: 'Account ID is required', + success: false, + }, + { status: 400 } + ); + } + + const { links } = await getLinks(accountId); + const sanitizedLinks = links.map(link => sanitizeMongoDocument(link)); + + return NextResponse.json({ + links: sanitizedLinks, + success: true, + }); + } catch (error) { + logger.error('Error getting user links:', error); + return NextResponse.json( + { + message: 'Failed to retrieve user links', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/users/[accountId]/route.ts b/src/app/api/admin/users/[accountId]/route.ts new file mode 100644 index 0000000..6c0d072 --- /dev/null +++ b/src/app/api/admin/users/[accountId]/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { getUserById } from '@/lib/userdb'; +import logger from '@/lib/logger'; +import { sanitizeMongoDocument } from '@/lib/utils'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ accountId: string }> } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { accountId } = await params; + + if (!accountId) { + return NextResponse.json( + { + message: 'Account ID is required', + success: false, + }, + { status: 400 } + ); + } + + const user = await getUserById(accountId); + + if (!user) { + return NextResponse.json( + { + message: 'User not found', + success: false, + }, + { status: 404 } + ); + } + + const sanitizedUser = sanitizeMongoDocument(user); + + return NextResponse.json({ + user: sanitizedUser, + success: true, + }); + } catch (error) { + logger.error('Error getting user:', error); + return NextResponse.json( + { + message: 'Failed to retrieve user', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/users/[accountId]/sessions/revoke/route.ts b/src/app/api/admin/users/[accountId]/sessions/revoke/route.ts new file mode 100644 index 0000000..5d4480f --- /dev/null +++ b/src/app/api/admin/users/[accountId]/sessions/revoke/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { revokeSession } from '@/lib/sessiondb'; +import logger from '@/lib/logger'; + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { sessionId, accountId } = await req.json(); + + if (!sessionId || !accountId) { + return NextResponse.json( + { + message: 'Session ID and Account ID are required', + success: false, + }, + { status: 400 } + ); + } + + const result = await revokeSession(sessionId, accountId); + + return NextResponse.json({ + ...result, + }); + } catch (error) { + logger.error('Error revoking session:', { error }); + return NextResponse.json( + { + message: 'Failed to revoke session', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/users/[accountId]/sessions/route.ts b/src/app/api/admin/users/[accountId]/sessions/route.ts new file mode 100644 index 0000000..afc003c --- /dev/null +++ b/src/app/api/admin/users/[accountId]/sessions/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { getSessions } from '@/lib/sessiondb'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ accountId: string }> } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { accountId } = await params; + + if (!accountId) { + return NextResponse.json( + { + message: 'Account ID is required', + success: false, + }, + { status: 400 } + ); + } + + const sessions = await getSessions(accountId); + const sanitizedSessions = Array.isArray(sessions) + ? sessions.map(session => sanitizeMongoDocument(session)) + : []; + + return NextResponse.json({ + sessions: sanitizedSessions, + success: true, + }); + } catch (error) { + logger.error('Error getting user sessions:', error); + return NextResponse.json( + { + message: 'Failed to retrieve user sessions', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..e449ab3 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { listUsers, removeUser, isUserAdmin } from '@/lib/userdb'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +export async function GET() { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const result = await listUsers(); + + if (result.users) { + const sanitizedUsers = result.users.map(user => sanitizeMongoDocument(user)); + + return NextResponse.json({ + users: sanitizedUsers, + total: result.total, + success: true, + }); + } + + return NextResponse.json( + { + message: result.return.status, + success: result.return.success, + }, + { status: result.return.success ? 200 : 500 } + ); + } catch (error) { + logger.error('Error getting users:', { error }); + return NextResponse.json( + { + message: 'Failed to retrieve users', + success: false, + }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { account_id } = await req.json(); + + if (!account_id) { + return NextResponse.json( + { + message: 'Account ID is required', + success: false, + }, + { status: 400 } + ); + } + + if (account_id === session.user.accountId) { + return NextResponse.json( + { + message: 'You cannot delete your own account from admin panel', + success: false, + }, + { status: 400 } + ); + } + + if (await isUserAdmin(account_id)) { + return NextResponse.json( + { + message: 'Cannot delete admin accounts', + success: false, + }, + { status: 400 } + ); + } + + const result = await removeUser(account_id); + + return NextResponse.json({ + message: result.status, + success: result.success, + }); + } catch (error) { + logger.error('Error deleting user:', { error }); + return NextResponse.json( + { + message: 'Failed to delete user', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts new file mode 100644 index 0000000..ceac02b --- /dev/null +++ b/src/app/api/analytics/route.ts @@ -0,0 +1,240 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAllAnalytics, removeAnalytics, removeAllAnalytics } from '@/lib/analyticsdb'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import logger from '@/lib/logger'; + +// View analytics for a specific link +// Example: /api/analytics?link_id=SHORT_ID&page=1&limit=50&startDate=2025-01-01&endDate=2025-12-31 +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Analytics request failed due to unauthorized access', { url: req.url }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); + } + + const account_id = session.user.accountId; + const url = new URL(req.url); + + const link_id = url.searchParams.get('link_id'); + if (!link_id) { + logger.info('Analytics request failed due to missing link_id', { account_id, url: req.url }); + return NextResponse.json( + { + message: 'Missing link_id parameter', + success: false, + }, + { status: 400 } + ); + } + const all = url.searchParams.get('all') === 'true'; + + let page = 1; + let limit = 50; + + if (!all) { + page = parseInt(url.searchParams.get('page') || '1'); + limit = parseInt(url.searchParams.get('limit') || '50'); + } + + let startDate: Date | undefined; + let endDate: Date | undefined; + + if (url.searchParams.get('startDate')) { + startDate = new Date(url.searchParams.get('startDate')!); + } + + if (url.searchParams.get('endDate')) { + endDate = new Date(url.searchParams.get('endDate')!); + } + + const queryOptions = all + ? {} + : { + page, + limit, + startDate, + endDate, + }; + + const { analytics, total } = await getAllAnalytics(account_id, link_id, queryOptions); + + return NextResponse.json( + { + message: 'Analytics retrieved successfully', + success: true, + analytics, + pagination: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }, + { status: 200 } + ); + } catch (error) { + logger.error('Analytics retrieval error:', { error, url: req.url }); + return NextResponse.json( + { + message: 'Failed to retrieve analytics', + success: false, + }, + { status: 500 } + ); + } +} + +// Delete analytics record +export async function DELETE(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Analytics deletion request failed due to unauthorized access', { url: req.url }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); + } + + const account_id = session.user.accountId; + let link_id, analytics_id, delete_all; + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + link_id = body.link_id; + analytics_id = body.analytics_id; + delete_all = body.delete_all; + } + } catch { + logger.info('Analytics deletion request failed due to missing parameters', { url: req.url }); + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); + } + + if (!link_id) { + logger.info('Analytics deletion request failed due to missing link_id', { + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: 'Missing link_id parameter', + success: false, + }, + { status: 400 } + ); + } + + // Delete all analytics for a link + if (delete_all) { + const result = await removeAllAnalytics(account_id, link_id); + + if (result.success) { + logger.info('All analytics deletion request succeeded', { + account_id, + link_id, + url: req.url, + }); + return NextResponse.json( + { + message: 'All analytics records deleted successfully', + success: true, + }, + { status: 200 } + ); + } else { + logger.info('All analytics deletion request failed', { + error: result.status, + account_id, + link_id, + url: req.url, + }); + return NextResponse.json( + { + message: result.status, + success: false, + }, + { status: 404 } + ); + } + } + + // Delete single analytics record + if (!analytics_id) { + logger.info('Analytics deletion request failed due to missing analytics_id', { + account_id, + link_id, + url: req.url, + }); + return NextResponse.json( + { + message: 'Missing analytics_id parameter for single record deletion', + success: false, + }, + { status: 400 } + ); + } + + const result = await removeAnalytics(account_id, link_id, analytics_id); + + if (result.success) { + logger.info('Single analytics record deletion request succeeded', { + account_id, + link_id, + analytics_id, + url: req.url, + }); + return NextResponse.json( + { + message: 'Analytics record deleted successfully', + success: true, + }, + { status: 200 } + ); + } else { + logger.info('Single analytics record deletion request failed', { + error: result.status, + account_id, + link_id, + analytics_id, + url: req.url, + }); + return NextResponse.json( + { + message: result.status, + success: false, + }, + { status: 404 } + ); + } + } catch (error) { + logger.error('Analytics deletion error:', { error, url: req.url }); + return NextResponse.json( + { + message: 'Failed to delete analytics', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..118989e --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,96 @@ +import NextAuth, { NextAuthOptions, User } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import logger from '@/lib/logger'; +import { existsUser, isUserAdmin } from '@/lib/userdb'; +import { JWT } from 'next-auth/jwt'; +import { Session } from 'next-auth'; +import { createSession, updateSessionActivity, revokeSession } from '@/lib/sessiondb'; +import { headers } from 'next/headers'; + +export const authOptions: NextAuthOptions = { + providers: [ + CredentialsProvider({ + name: 'Account ID', + credentials: { + accountId: { label: 'Account ID', type: 'text', placeholder: 'Enter your Account ID' }, + }, + async authorize(credentials) { + const { accountId } = credentials as { accountId: string }; + + const exists = await existsUser(accountId); + + if (exists) { + const isAdmin = await isUserAdmin(accountId); + + return { + id: accountId, + accountId, + isAdmin, + }; + } + + return null; + }, + }), + ], + callbacks: { + async jwt({ token, user, trigger }: { token: JWT; user: User; trigger?: string }) { + if (user) { + token.accountId = user.accountId; + token.isAdmin = user.isAdmin; + + const headersList = await headers(); + const userAgent = headersList.get('user-agent') || 'Unknown'; + const ip = headersList.get('x-forwarded-for') || headersList.get('x-real-ip') || 'Unknown'; + + const { sessionId } = await createSession(user.accountId, userAgent, ip); + + token.sessionId = sessionId; + } + + if (trigger === 'update' && token.sessionId) { + await updateSessionActivity(token.sessionId as string); + } + + return token; + }, + async session({ session, token }: { session: Session; token: JWT }) { + if (token) { + session.user = { + ...session.user, + accountId: token.accountId as string, + isAdmin: token.isAdmin as boolean, + sessionId: token.sessionId as string, + }; + } + return session; + }, + }, + events: { + async signOut({ token }) { + if (token?.sessionId) { + try { + await revokeSession(token.sessionId as string, token.accountId as string); + } catch (error) { + logger.error('Error terminating session on signOut:', error); + } + } + }, + }, + pages: { + signIn: '/', + error: '/', + }, + session: { + strategy: 'jwt' as const, + maxAge: 30 * 24 * 60 * 60, // 30 days (session lifetime) + }, + jwt: { + maxAge: 5 * 60, // 5 min (token lifetime) + }, + secret: process.env.NEXTAUTH_SECRET || '/JZ9N+lqRtvspbAfs0HK41RkthPYuUdqxb+cuimYOXw=', +}; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/src/app/api/auth/check-session/route.ts b/src/app/api/auth/check-session/route.ts new file mode 100644 index 0000000..9ad5410 --- /dev/null +++ b/src/app/api/auth/check-session/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { isSessionValid } from '@/lib/sessiondb'; +import logger from '@/lib/logger'; + +export async function GET() { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId || !session?.user?.sessionId) { + return NextResponse.json( + { + valid: false, + message: 'No active session', + }, + { status: 401 } + ); + } + + const isValid = await isSessionValid(session.user.sessionId, session.user.accountId); + + if (!isValid) { + logger.info('Session check failed - revoked or expired session', { + sessionId: session.user.sessionId, + accountId: session.user.accountId, + }); + + return NextResponse.json( + { + valid: false, + message: 'Session has been revoked', + }, + { status: 401 } + ); + } + + return NextResponse.json({ valid: true }); + } catch (error) { + logger.error('Error checking session:', error); + return NextResponse.json( + { + valid: false, + message: 'Error checking session', + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..3984f8f --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createUser, isUserAdmin } from '@/lib/userdb'; +import logger from '@/lib/logger'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; + +export async function POST(req: NextRequest) { + try { + let is_admin; + logger.info('Registration request', { url: req.url }); + const session = await getServerSession(authOptions); + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + is_admin = body.is_admin; + } + } catch { + logger.info('Registration request failed due to missing parameters', { url: req.url }); + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); + } + + if (is_admin) { + const isAuthorized = await isUserAdmin(session?.user?.accountId); + + if (isAuthorized) { + const account = await createUser(is_admin); + logger.info('Account creation request succeeded (admin)', { is_admin, url: req.url }); + return NextResponse.json( + { + message: 'Admin registration successful', + success: true, + account_id: account.account_id, + }, + { status: 200 } + ); + } else { + logger.info('Registration request failed due to missing rights', { + is_admin, + url: req.url, + }); + return NextResponse.json( + { + message: 'Unauthorized admin registration attempt', + success: false, + }, + { status: 401 } + ); + } + } + + const account = await createUser(false); + + return NextResponse.json({ + message: 'Registration successful', + success: true, + account_id: account.account_id, + }); + } catch (error) { + logger.error('Registration error:', { error, url: req.url }); + return NextResponse.json( + { + message: 'Registration failed', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/remove/route.ts b/src/app/api/auth/remove/route.ts new file mode 100644 index 0000000..a33adf3 --- /dev/null +++ b/src/app/api/auth/remove/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { removeUser, isUserAdmin } from '@/lib/userdb'; +import logger from '@/lib/logger'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; + +export async function DELETE(req: NextRequest) { + try { + let account_id; + logger.info('Account removal request', { url: req.url }); + const session = await getServerSession(authOptions); + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + account_id = body.account_id; + } + } catch { + logger.info('Account removal request failed due to missing parameters', { url: req.url }); + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); + } + + if (session?.user?.accountId === account_id || (await isUserAdmin(session?.user?.accountId))) { + const accountRemovalResponse = await removeUser(account_id); + return NextResponse.json( + { + message: accountRemovalResponse.status, + success: accountRemovalResponse.success, + }, + { status: accountRemovalResponse.success ? 200 : 500 } + ); + } + + logger.info('Account removal request failed due to missing rights', { url: req.url }); + return NextResponse.json( + { + message: 'Unauthorized account removal attempt', + success: false, + }, + { status: 401 } + ); + } catch (error) { + logger.error('Account removal error:', { error, url: req.url }); + return NextResponse.json( + { + message: 'Account removal failed', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/sessions/revoke/route.ts b/src/app/api/auth/sessions/revoke/route.ts new file mode 100644 index 0000000..5654924 --- /dev/null +++ b/src/app/api/auth/sessions/revoke/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { revokeSession } from '@/lib/sessiondb'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { signOut } from 'next-auth/react'; +import logger from '@/lib/logger'; + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const { sessionId } = await req.json(); + + if (!sessionId) { + return NextResponse.json( + { + message: 'Session ID is required', + success: false, + }, + { status: 400 } + ); + } + + const result = await revokeSession(sessionId, session.user.accountId); + + const isCurrentSession = sessionId === session.user.sessionId; + if (isCurrentSession) { + signOut({ redirect: false }); + } + + return NextResponse.json({ + ...result, + isCurrentSession, + }); + } catch (error) { + logger.error('Error revoking session:', { error }); + return NextResponse.json( + { + message: 'Failed to revoking session', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/sessions/route.ts b/src/app/api/auth/sessions/route.ts new file mode 100644 index 0000000..2e96e06 --- /dev/null +++ b/src/app/api/auth/sessions/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { getSessions } from '@/lib/sessiondb'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import logger from '@/lib/logger'; + +export async function GET() { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); + } + + const sessions = await getSessions(session.user.accountId); + + // Mark the current session + const currentSessionId = session.user.sessionId; + const sessionsWithCurrent = sessions.map(s => ({ + ...s, + isCurrentSession: s.id === currentSessionId, + })); + + return NextResponse.json({ + sessions: sessionsWithCurrent, + success: true, + }); + } catch (error) { + logger.error('Error getting sessions:', { error }); + return NextResponse.json( + { + message: 'Failed to retrieve sessions', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/link/route.ts b/src/app/api/link/route.ts new file mode 100644 index 0000000..35b3eb7 --- /dev/null +++ b/src/app/api/link/route.ts @@ -0,0 +1,348 @@ +import { NextRequest, NextResponse } from 'next/server'; +import logger from '@/lib/logger'; +import { isValidUrl, sanitizeMongoDocument } from '@/lib/utils'; +import { getLinkById, createLink, editLink, removeLink } from '@/lib/linkdb'; +import { removeAllAnalytics } from '@/lib/analyticsdb'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; + +// View link +// Example: /api/link?shortId=SHORT_ID +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Link retrieval request failed due to unauthorized access', { url: req.url }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); + } + + const account_id = session.user.accountId; + + const url = new URL(req.url); + const shortId = url.searchParams.get('shortId'); + + if (!shortId) { + logger.info('Link retrieval request failed due to missing shortId', { + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: 'Missing shortId parameter', + success: false, + }, + { status: 400 } + ); + } + + const { link, return: returnValue } = await getLinkById(account_id, shortId); + + if (returnValue.success && link) { + return NextResponse.json( + { + message: 'Link retrieved successfully', + success: true, + link: sanitizeMongoDocument(link), + }, + { status: 200 } + ); + } + + logger.info('Link retrieval request failed', { + error: returnValue.status, + account_id, + shortId, + url: req.url, + }); + return NextResponse.json( + { + message: returnValue.status || 'Link not found', + success: false, + }, + { status: 404 } + ); + } catch (error) { + logger.error('Link retrieval error:', { error, url: req.url }); + return NextResponse.json( + { + message: 'Failed to retrieve link', + success: false, + }, + { status: 500 } + ); + } +} + +// Create link +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Link creation request failed due to unauthorized access', { url: req.url }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); + } + + const account_id = session.user.accountId; + let target_url; + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + target_url = body.target_url; + } + } catch { + logger.info('Link creation request failed due to missing parameters', { url: req.url }); + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); + } + + if (!target_url || !isValidUrl(target_url)) { + logger.info('Link creation request failed due to invalid URL', { account_id, url: req.url }); + return NextResponse.json( + { + message: 'Invalid URL. Please provide a valid URL with http:// or https://', + success: false, + }, + { status: 400 } + ); + } + + const { shortId, return: returnValue } = await createLink(account_id, target_url); + + if (returnValue.success) { + return NextResponse.json( + { + message: 'Link Creation succeeded', + success: true, + shortId, + }, + { status: 200 } + ); + } + + logger.error('Link creation request failed', { + error: returnValue.status, + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: returnValue.status || 'Link creation failed', + success: false, + }, + { status: 422 } + ); + } catch (error) { + logger.error('Link creation error:', { error, url: req.url }); + return NextResponse.json( + { + message: 'Link creation failed', + success: false, + }, + { status: 500 } + ); + } +} + +// Edit link +export async function PATCH(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Link edit request failed due to unauthorized access', { url: req.url }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); + } + + const account_id = session.user.accountId; + let shortId, target_url; + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + shortId = body.shortId; + target_url = body.target_url; + } + } catch { + logger.info('Link edit request failed due to missing parameters', { url: req.url }); + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); + } + + if (!shortId) { + logger.info('Link edit request failed due to missing shortId', { account_id, url: req.url }); + return NextResponse.json( + { + message: 'Missing shortId parameter', + success: false, + }, + { status: 400 } + ); + } + + if (!target_url || !isValidUrl(target_url)) { + logger.info('Link edit request failed due to invalid URL', { account_id, url: req.url }); + return NextResponse.json( + { + message: 'Invalid URL. Please provide a valid URL with http:// or https://', + success: false, + }, + { status: 400 } + ); + } + + const returnValue = await editLink(account_id, shortId, target_url); + + if (returnValue.success) { + return NextResponse.json( + { + message: 'Link updated successfully', + success: true, + }, + { status: 200 } + ); + } + + logger.error('Link edit request failed', { + error: returnValue.status, + account_id, + shortId, + url: req.url, + }); + return NextResponse.json( + { + message: returnValue.status || 'Link update failed', + success: false, + }, + { status: 422 } + ); + } catch (error) { + logger.error('Link edit error:', { error, url: req.url }); + return NextResponse.json( + { + message: 'Link update failed', + success: false, + }, + { status: 500 } + ); + } +} + +// Delete link +export async function DELETE(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Link removal request failed due to unauthorized access', { url: req.url }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); + } + + const account_id = session.user.accountId; + let shortId; + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + shortId = body.shortId; + } + } catch { + logger.info('Link removal request failed due to missing parameters', { url: req.url }); + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); + } + + if (!shortId) { + logger.info('Link removal request failed due to missing shortId', { + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: 'Missing shortId parameter', + success: false, + }, + { status: 400 } + ); + } + + await removeAllAnalytics(account_id, shortId); + const returnValue = await removeLink(account_id, shortId); + + if (returnValue.success) { + return NextResponse.json( + { + message: 'Link removal succeeded', + success: true, + }, + { status: 200 } + ); + } + + logger.error('Link removal request failed', { + error: returnValue.status, + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: returnValue.status || 'Link removal failed', + success: false, + }, + { status: 422 } + ); + } catch (error) { + logger.error('Link removal error:', { error, url: req.url }); + return NextResponse.json( + { + message: 'Link removal failed', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/links/route.ts b/src/app/api/links/route.ts new file mode 100644 index 0000000..bd3216b --- /dev/null +++ b/src/app/api/links/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getLinks } from '@/lib/linkdb'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +// Get all links +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Links retrieval request failed due to unauthorized access', { url: req.url }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); + } + + const account_id = session.user.accountId; + const { links, return: returnValue } = await getLinks(account_id); + + if (returnValue.success) { + return NextResponse.json( + { + message: 'Links retrieved successfully', + success: true, + links: links.map(link => sanitizeMongoDocument(link)), + }, + { status: 200 } + ); + } + + logger.info('Links retrieval request failed', { + error: returnValue.status, + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: returnValue.status || 'Failed to retrieve links', + success: false, + }, + { status: 404 } + ); + } catch (error) { + logger.error('Links retrieval error:', { error, url: req.url }); + return NextResponse.json( + { + message: 'Failed to retrieve links', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/statistics/route.ts b/src/app/api/statistics/route.ts new file mode 100644 index 0000000..1a03658 --- /dev/null +++ b/src/app/api/statistics/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { getAllStats, updateStats } from '@/lib/statisticsdb'; + +export async function GET() { + try { + let stats = await getAllStats(); + + if ( + !stats || + !stats.last_updated || + new Date().getTime() - new Date(stats.last_updated).getTime() > 5 * 60 * 1000 + ) { + // 5min + await updateStats(); + stats = await getAllStats(); + } + + if (!stats) { + return NextResponse.json( + { + message: 'Failed to retrieve statistics', + success: false, + }, + { status: 500 } + ); + } + + return NextResponse.json({ + message: 'Statistics retrieved successfully', + success: true, + stats, + }); + } catch (error) { + console.error('Statistics retrieval error:', error); + return NextResponse.json( + { + message: 'An error occurred while retrieving statistics', + success: false, + }, + { status: 500 } + ); + } +} diff --git a/src/app/dashboard/Dashboard.module.css b/src/app/dashboard/Dashboard.module.css new file mode 100644 index 0000000..0c8d881 --- /dev/null +++ b/src/app/dashboard/Dashboard.module.css @@ -0,0 +1,165 @@ +.dashboardContainer { + max-width: 75rem; + width: 100%; + margin: 0 auto; + padding: 2rem 1rem; +} + +.dashboardHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.dashboardTitle { + font-size: 2rem; + color: var(--text-primary); + margin: 0; +} + +.actionButtons { + display: flex; + gap: 1rem; +} + +.securityButton, +.adminButton { + padding: 0.5rem 1rem; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.securityButton { + background-color: var(--accent); + color: white; +} + +.adminButton { + background-color: #8b5cf6; + color: white; +} + +.securityButton:hover, +.adminButton:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.urlShortener { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + border: 1px solid var(--border-color); +} + +.createForm { + margin-top: 1rem; +} + +.inputGroup { + display: flex; + gap: 1rem; +} + +.urlInput { + flex: 1; + padding: 0.75rem 1rem; + border-radius: 4px; + border: 1px solid var(--border-color); + background-color: var(--input-bg); + color: var(--text-primary); + font-size: 1rem; +} + +.createButton { + padding: 0 1.5rem; + border-radius: 4px; + border: none; + background-color: var(--accent); + color: white; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.createButton:hover { + background-color: var(--accent-hover); +} + +.createButton:disabled { + background-color: var(--accent-disabled); + cursor: not-allowed; +} + +.linksSection { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + border: 1px solid var(--border-color); +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + color: var(--text-secondary); + font-size: 1rem; +} + +.noLinks { + text-align: center; + padding: 2rem; + color: var(--text-secondary); + font-style: italic; +} + +@media (max-width: 1200px) { + .dashboardContainer { + max-width: 100%; + padding: 1.5rem; + } +} + +@media (max-width: 768px) { + .inputGroup { + flex-direction: column; + } + + .dashboardHeader { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .actionButtons { + width: 100%; + justify-content: flex-start; + } + + .urlShortener, + .linksSection { + padding: 1rem; + } +} + +@media (max-width: 480px) { + .dashboardContainer { + padding: 1rem 0.5rem; + } + + .dashboardTitle { + font-size: 1.5rem; + } + + .securityButton, + .adminButton { + padding: 0.4rem 0.8rem; + font-size: 0.9rem; + } +} diff --git a/src/app/dashboard/link/[shortId]/LinkDetail.module.css b/src/app/dashboard/link/[shortId]/LinkDetail.module.css new file mode 100644 index 0000000..b25ef1f --- /dev/null +++ b/src/app/dashboard/link/[shortId]/LinkDetail.module.css @@ -0,0 +1,280 @@ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.backLink { + padding: 0.5rem 1rem; + border-radius: 4px; + background-color: var(--accent); + color: var(--text-primary); + text-decoration: none; + font-weight: 500; + transition: all 0.2s ease; +} + +.backLink:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.title { + font-size: 2rem; + margin: 0; + color: var(--text-primary); +} + +.linkInfo { + margin-bottom: 2rem; +} + +.linkCard { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: var(--card-shadow); +} + +.linkCard h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: var(--text-primary); + font-size: 1.5rem; +} + +.linkDetails { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.linkDetailItem { + display: flex; + flex-direction: column; +} + +.label { + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +.value { + color: var(--text-primary); + word-break: break-all; +} + +.shortUrl { + color: var(--primary-color); + word-break: break-all; + text-decoration: none; +} + +.shortUrl:hover { + text-decoration: underline; +} + +.defaultButton { + background-color: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + cursor: pointer; +} + +.defaultButton:hover { + background-color: var(--primary-color); + color: white; +} + +.targetUrlSection { + margin-top: 0.5rem; +} + +.targetUrlHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.targetUrl { + color: var(--text-primary); + word-break: break-all; + text-decoration: none; +} + +.targetUrl:hover { + text-decoration: underline; + color: var(--primary-color); +} + +.editForm { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.urlInput { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + background-color: var(--input-bg); + color: var(--text-primary); +} + +.editActions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.cancelButton { + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.cancelButton:hover { + background-color: var(--border-color); +} + +.saveButton { + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.saveButton:hover { + background-color: var(--primary-dark); +} + +.analyticsSection { + margin-top: 2rem; +} + +.analyticsHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} + +.analyticsHeader h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.5rem; +} + +.totalClicks { + color: var(--text-secondary); + font-weight: 600; +} + +.deleteAllButton { + background-color: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 1rem; + cursor: pointer; +} + +.deleteAllButton:hover { + background-color: var(--primary-color); + color: white; +} + +.graphs { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.graphCard { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1rem; + box-shadow: var(--card-shadow); +} + +.graphCard h3 { + margin-top: 0; + margin-bottom: 1rem; + color: var(--text-primary); + font-size: 1rem; + text-align: center; +} + +.noAnalytics { + display: flex; + justify-content: center; + align-items: center; + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 2rem; + box-shadow: var(--card-shadow); +} + +.noAnalytics p { + color: var(--text-secondary); + font-size: 1rem; + text-align: center; +} + +.loadingContainer { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 300px; + gap: 1rem; +} + +.loader { + border: 4px solid rgba(0, 0, 0, 0.1); + border-left-color: var(--primary-color); + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 768px) { + .analyticsHeader { + flex-direction: column; + align-items: flex-start; + } + + .graphs { + grid-template-columns: 1fr; + } +} diff --git a/src/app/dashboard/link/[shortId]/page.tsx b/src/app/dashboard/link/[shortId]/page.tsx new file mode 100644 index 0000000..5d3c548 --- /dev/null +++ b/src/app/dashboard/link/[shortId]/page.tsx @@ -0,0 +1,530 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useToast } from '@/contexts/ToastContext'; +import Graph from '@/components/ui/Graph'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import AnalyticsTable from '@/components/ui/dashboard/link/AnalyticsTable'; +import styles from './LinkDetail.module.css'; +import { Analytics } from '@/types/analytics'; +import type { Link as LinkType } from '@/types/link'; +import type { StatItem } from '@/types/statistics'; + +export default function LinkDetailPage() { + const params = useParams(); + const router = useRouter(); + const { showToast } = useToast(); + const shortId = params.shortId as string; + + const [link, setLink] = useState(null); + const [targetUrl, setTargetUrl] = useState(''); + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [allAnalytics, setAllAnalytics] = useState([]); + const [analytics, setAnalytics] = useState([]); + const [totalAnalytics, setTotalAnalytics] = useState(0); + const [page, setPage] = useState(1); + const [limit] = useState(15); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showDeleteAllModal, setShowDeleteAllModal] = useState(false); + const [analyticsToDelete, setAnalyticsToDelete] = useState(''); + const [isLoadingStats, setIsLoadingStats] = useState(true); + const isRedirecting = useRef(false); + + // Stats data + const [browserStats, setBrowserStats] = useState([]); + const [osStats, setOsStats] = useState([]); + const [countryStats, setCountryStats] = useState([]); + const [ipVersionStats, setIpVersionStats] = useState([]); + + function isValidUrl(urlStr: string): boolean { + if (urlStr.trim() === '') { + return false; + } + + try { + const parsedUrl = new URL(urlStr); + return parsedUrl.protocol !== '' && parsedUrl.hostname !== ''; + } catch { + return false; + } + } + + useEffect(() => { + if (isEditing && inputRef.current) { + setTimeout(() => { + inputRef.current?.focus(); + }, 10); + } + }, [isEditing]); + + useEffect(() => { + if (isRedirecting.current) return; + + async function fetchLinkData() { + try { + const response = await fetch(`/api/link?shortId=${shortId}`); + if (!response.ok) { + showToast('Failed to load link details', 'error'); + isRedirecting.current = true; + router.push('/dashboard'); + return; + } + + const data = await response.json(); + if (data.success && data.link) { + setLink(data.link); + setTargetUrl(data.link.target_url); + } else { + showToast(data.message || 'Link not found', 'error'); + isRedirecting.current = true; + router.push('/dashboard'); + } + } catch { + showToast('An error occurred while loading link details', 'error'); + isRedirecting.current = true; + router.push('/dashboard'); + } finally { + setIsLoading(false); + } + } + + fetchLinkData(); + }, [shortId, router, showToast]); + + useEffect(() => { + if (!link) return; + + async function fetchAllAnalytics() { + try { + const response = await fetch(`/api/analytics?link_id=${shortId}&all=true`); + if (!response.ok) { + showToast('Failed to load complete analytics data', 'error'); + return; + } + + const data = await response.json(); + if (data.success) { + setAllAnalytics(data.analytics); + setTotalAnalytics(data.pagination.total); + } + } catch { + showToast('An error occurred while loading complete analytics data', 'error'); + } + } + + fetchAllAnalytics(); + }, [link, shortId, showToast]); + + useEffect(() => { + if (!link) return; + + async function fetchPaginatedAnalytics() { + try { + const response = await fetch( + `/api/analytics?link_id=${shortId}&page=${page}&limit=${limit}` + ); + if (!response.ok) { + showToast('Failed to load analytics page', 'error'); + return; + } + + const data = await response.json(); + if (data.success) { + setAnalytics(data.analytics); + } + } catch { + showToast('An error occurred while loading analytics page', 'error'); + } + } + + fetchPaginatedAnalytics(); + }, [link, shortId, page, limit, showToast]); + + useEffect(() => { + if (!link || allAnalytics.length === 0) return; + + async function generateStats() { + setIsLoadingStats(true); + try { + // Browser stats + const browsers = allAnalytics.reduce((acc: Record, item) => { + const browser = item.browser || 'Unknown'; + acc[browser] = (acc[browser] || 0) + 1; + return acc; + }, {}); + + // OS stats + const oses = allAnalytics.reduce((acc: Record, item) => { + const os = item.platform || 'Unknown'; + acc[os] = (acc[os] || 0) + 1; + return acc; + }, {}); + + // Country stats + const countries = allAnalytics.reduce((acc: Record, item) => { + const country = item.country || 'Unknown'; + acc[country] = (acc[country] || 0) + 1; + return acc; + }, {}); + + // IP version stats + const ipVersions = allAnalytics.reduce((acc: Record, item) => { + const ipVersion = item.ip_version || 'Unknown'; + acc[ipVersion] = (acc[ipVersion] || 0) + 1; + return acc; + }, {}); + + // Convert to StatItem[] and sort by count + setBrowserStats( + Object.entries(browsers) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); + + setOsStats( + Object.entries(oses) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); + + setCountryStats( + Object.entries(countries) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); + + setIpVersionStats( + Object.entries(ipVersions) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); + } catch { + showToast('An error occurred while processing analytics data', 'error'); + } finally { + setIsLoadingStats(false); + } + } + + generateStats(); + }, [allAnalytics, link, showToast]); + + const handlePageChange = (newPage: number) => { + setPage(newPage); + }; + + const handleEditLink = async () => { + if (!isValidUrl(targetUrl)) { + showToast('Please enter a valid URL', 'error'); + return; + } + + try { + const response = await fetch(`/api/link`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + shortId: shortId, + target_url: targetUrl, + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('Link updated successfully', 'success'); + setIsEditing(false); + if (link) { + setLink({ + ...link, + target_url: targetUrl, + last_modified: new Date(), + }); + } + } else { + showToast(data.message || 'Failed to update link', 'error'); + } + } catch { + showToast('An error occurred while updating the link', 'error'); + } + }; + + const handleDeleteAnalytics = async () => { + try { + const response = await fetch('/api/analytics', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + link_id: shortId, + analytics_id: analyticsToDelete, + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('Analytics entry deleted successfully', 'success'); + + setAnalytics(analytics.filter(item => item._id?.toString() !== analyticsToDelete)); + + setTotalAnalytics(prev => prev - 1); + } else { + showToast(data.message || 'Failed to delete analytics entry', 'error'); + } + } catch { + showToast('An error occurred while deleting the analytics entry', 'error'); + } finally { + setShowDeleteModal(false); + setAnalyticsToDelete(''); + } + }; + + const handleDeleteAllAnalytics = async () => { + try { + const response = await fetch('/api/analytics', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + link_id: shortId, + delete_all: true, + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('All analytics entries deleted successfully', 'success'); + + setAnalytics([]); + setTotalAnalytics(0); + setBrowserStats([]); + setOsStats([]); + setCountryStats([]); + setIpVersionStats([]); + } else { + showToast(data.message || 'Failed to delete all analytics entries', 'error'); + } + } catch { + showToast('An error occurred while deleting all analytics entries', 'error'); + } finally { + setShowDeleteAllModal(false); + } + }; + + if (isLoading) { + return ( +
+
+

Loading link details...

+
+ ); + } + + return ( +
+
+

Link Details

+ + Back to Dashboard + +
+ +
+
+

Link Information

+
+
+ Short ID: + {shortId} +
+ +
+ + +
+ +
+ Created: + + {link ? new Date(link.created_at).toLocaleString() : ''} + +
+ +
+ Last Modified: + + {link ? new Date(link.last_modified).toLocaleString() : ''} + +
+ +
+
+ Target URL: + {!isEditing && ( + + )} +
+ + {isEditing ? ( +
{ + e.preventDefault(); + handleEditLink(); + }} + className={styles.editForm} + > + setTargetUrl(e.target.value)} + className={styles.urlInput} + placeholder='https://example.com' + /> +
+ + +
+
+ ) : ( + + {link?.target_url} + + )} +
+
+
+
+ +
+
+

Analytics

+ Total Clicks: {totalAnalytics} + {totalAnalytics > 0 && ( + + )} +
+ + {totalAnalytics > 0 ? ( + <> +
+
+

Browsers

+ +
+ +
+

Operating Systems

+ +
+ +
+

Countries

+ +
+ +
+

IP Versions

+ +
+
+ + { + setAnalyticsToDelete(id); + setShowDeleteModal(true); + }} + /> + + ) : ( +
+

No clicks recorded yet for this link.

+
+ )} +
+ + {/* Confirm Delete Modal */} + { + setShowDeleteModal(false); + setAnalyticsToDelete(''); + }} + /> + + {/* Confirm Delete All Modal */} + setShowDeleteAllModal(false)} + /> +
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..937a79d --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import LinkTable from '@/components/ui/dashboard/LinkTable'; +import styles from './Dashboard.module.css'; +import { useToast } from '@/contexts/ToastContext'; + +export default function Dashboard() { + const { data: session, status } = useSession(); + const router = useRouter(); + const { showToast } = useToast(); + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (status === 'unauthenticated') { + router.push('/'); + } + }, [status, router]); + + useEffect(() => { + if (session?.user?.accountId) { + fetchLinks(); + } + }, [session]); + + const fetchLinks = async () => { + try { + setLoading(true); + const response = await fetch('/api/links'); + + if (!response.ok) { + throw new Error('Failed to fetch links'); + } + + const data = await response.json(); + if (data.success) { + setLinks(data.links); + } else { + showToast(data.message || 'Failed to load links', 'error'); + } + } catch (error) { + console.error('Error fetching links:', error); + showToast('Failed to load links', 'error'); + } finally { + setLoading(false); + } + }; + + if (status === 'loading') { + return
Loading...
; + } + + if (status === 'unauthenticated') { + return null; + } + + return ( +
+
+

Dashboard

+
+ + + + + {session?.user?.isAdmin && ( + + + + )} +
+
+ +
+

Create New Short Link

+ +
+ +
+

Your Shortened Links

+ {loading ? ( +
Loading your links...
+ ) : links.length === 0 ? ( +
+ You haven't created any links yet. Create your first short link above! +
+ ) : ( + + )} +
+
+ ); +} + +interface CreateLinkFormProps { + onLinkCreated: () => void; +} + +function CreateLinkForm({ onLinkCreated }: CreateLinkFormProps) { + const [url, setUrl] = useState(''); + const [creating, setCreating] = useState(false); + const { showToast } = useToast(); + + const handleSubmit = async (e: { preventDefault: () => void }) => { + e.preventDefault(); + + if (!url.trim()) { + showToast('Please enter a URL', 'error'); + return; + } + + try { + setCreating(true); + const response = await fetch('/api/link', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ target_url: url }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('Link created successfully!', 'success'); + setUrl(''); + if (onLinkCreated) onLinkCreated(); + } else { + showToast(data.message || 'Failed to create link', 'error'); + } + } catch (error) { + console.error('Error creating link:', error); + showToast('Failed to create link', 'error'); + } finally { + setCreating(false); + } + }; + + return ( +
+
+ setUrl(e.target.value)} + placeholder='Enter URL to shorten' + className={styles.urlInput} + required + /> + +
+
+ ); +} diff --git a/src/app/dashboard/security/Security.module.css b/src/app/dashboard/security/Security.module.css new file mode 100644 index 0000000..4388976 --- /dev/null +++ b/src/app/dashboard/security/Security.module.css @@ -0,0 +1,120 @@ +.container { + max-width: 75rem; + width: 100%; + margin: 0 auto; + padding: 2rem 1rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.header h1 { + font-size: 2rem; + color: var(--text-primary); + margin: 0; +} + +.backLink { + padding: 0.5rem 1rem; + border-radius: 4px; + background-color: var(--accent); + color: var(--text-primary); + text-decoration: none; + font-weight: 500; + transition: all 0.2s ease; +} + +.backLink:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.content { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.sessionSection, +.dangerSection { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + border: 1px solid var(--border-color); +} + +.sessionSection h2, +.dangerSection h2 { + font-size: 1.6rem; + margin: 0 0 1.5rem 0; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-color); + font-weight: 600; +} + +.dangerSection h2 { + color: #dc3545; + border-bottom-color: rgba(220, 53, 69, 0.3); +} + +.sessionSection { + margin-bottom: 1rem; +} + +.dangerCard { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + background-color: rgba(220, 53, 69, 0.05); + border: 1px solid rgba(220, 53, 69, 0.2); + border-radius: 4px; +} + +.dangerInfo h3 { + margin-top: 0; + color: #dc3545; +} + +.dangerInfo p { + color: var(--text-secondary); + margin-bottom: 0; +} + +.deleteAccountBtn { + padding: 0.5rem 1rem; + background-color: #dc3545; + color: white; + border: none; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.deleteAccountBtn:hover { + background-color: #c82333; +} + +/* Responsive styles */ +@media (max-width: 768px) { + .header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .dangerCard { + flex-direction: column; + gap: 1.5rem; + align-items: flex-start; + } + + .deleteAccountBtn { + width: 100%; + } +} diff --git a/src/app/dashboard/security/page.tsx b/src/app/dashboard/security/page.tsx new file mode 100644 index 0000000..b77c1a8 --- /dev/null +++ b/src/app/dashboard/security/page.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useState } from 'react'; +import { useSession, signOut } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useToast } from '@/contexts/ToastContext'; +import SessionManager from '@/components/ui/dashboard/SessionManager'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import styles from './Security.module.css'; + +export default function SecurityPage() { + const router = useRouter(); + const { data: session } = useSession(); + const { showToast } = useToast(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const handleAccountDeletion = async () => { + try { + setIsDeleting(true); + + const response = await fetch('/api/auth/remove', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ account_id: session?.user?.accountId }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('Account deleted successfully', 'success'); + + await signOut({ redirect: false }); + router.push('/'); + } else { + showToast(data.message || 'Failed to delete account', 'error'); + setIsDeleteModalOpen(false); + } + } catch (error) { + console.error('Error deleting account:', error); + showToast('Failed to delete account', 'error'); + setIsDeleteModalOpen(false); + } finally { + setIsDeleting(false); + } + }; + + return ( +
+
+

Security Settings

+ + Back to Dashboard + +
+ +
+
+ +
+ +
+

Danger Zone

+
+
+

Delete Account

+

+ This will permanently delete your account and all associated data. This action + cannot be undone. +

+
+ +
+
+
+ + setIsDeleteModalOpen(false)} + /> +
+ ); +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..ff8fc09 Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..15422cc --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,67 @@ +@import "tailwindcss"; + +/* Dark theme colors */ +:root { + --background: #ffffff; + --foreground: #171717; + --primary-color: #6366f1; + --primary-dark: #4143be; + --bg-primary: #121212; + --bg-secondary: #1e1e1e; + --text-primary: #f0f0f0; + --text-secondary: #a0a0a0; + --disabled-text: #777777; + --accent: #6366f1; + --accent-hover: #4f46e5; + --accent-disabled: #3a3cb6; + --card-bg: #252525; + --card-secondary-bg: #1d1d1d; + --card-shadow: #000000; + --border-color: #333333; + --success: #10b981; + --warning: #f59e0b; + --error: #ef4444; + + --bg-color: #141313; + --disabled-bg: #161515; + --hover-bg: #1b1b1b; + --input-bg: #1d1d1d; + --table-header-bg: var(--card-secondary-bg); + + --header-min-height: 4.5rem; + --footer-min-height: 3.5rem; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); +} + +body { + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + margin: 0; + padding: 0; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.page-content { + flex: 1; + display: flex; + flex-direction: column; + width: 100%; + min-height: 100vh; + box-sizing: border-box; + padding-top: var(--header-min-height); + padding-bottom: var(--footer-min-height); +} + +@media (max-width: 600px) { + .page-content { + padding-top: calc(var(--header-min-height) + 2rem); + padding-bottom: calc(var(--footer-min-height) + 2rem); + } +} diff --git a/src/app/l/[shortId]/route.ts b/src/app/l/[shortId]/route.ts new file mode 100644 index 0000000..d2e4bcd --- /dev/null +++ b/src/app/l/[shortId]/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getTargetUrl } from '@/lib/linkdb'; +import { getClientInfo } from '@/lib/utils'; +import { saveAnalytics } from '@/lib/analyticsdb'; +import logger from '@/lib/logger'; + +export async function GET(req: NextRequest, { params }: { params: Promise<{ shortId: string }> }) { + try { + const { shortId } = await params; + + const link = await getTargetUrl(shortId); + + if (!link || !link.target_url) { + return NextResponse.redirect(new URL('/not-found', req.url)); + } + + const clientInfo = await getClientInfo(req); + + const analyticsData = { + link_id: shortId, + account_id: link.account_id, + ...clientInfo, + }; + + saveAnalytics(analyticsData).catch(err => + logger.error('Failed to save analytics', { error: err, shortId }) + ); + + return NextResponse.redirect(new URL(link.target_url)); + } catch (error) { + logger.error('Link redirection error', error); + return NextResponse.redirect(new URL('/error', req.url)); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..560a782 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,48 @@ +import Providers from '@/components/Providers'; +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import Header from '@/components/header'; +import Footer from '@/components/footer'; +import { ToastProvider } from '@/contexts/ToastContext'; +import Toast from '@/components/ui/Toast'; +import ResponsiveLayout from '@/components/ResponsiveLayout'; +import SessionMonitor from '@/components/SessionMonitor'; +import './globals.css'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: 'µLinkShortener', + description: 'Create short links and see who accessed them!', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + +
+
{children}
+