mirror of
https://github.com/Kizuren/uLinkShortener.git
synced 2025-12-21 13:06:19 +01:00
finished µLinkShortener v2
This commit is contained in:
parent
ef76ad92ec
commit
71bdd08da9
117 changed files with 10892 additions and 1685 deletions
|
|
@ -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
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
MONGO_URI=mongodb://localhost:27017/uLinkShortener
|
||||
PORT=5000
|
||||
MONGO_URI=mongodb://<username>:<password>@<host>:<port>/<database>
|
||||
MONGO_DB_NAME=<database>
|
||||
NEXTAUTH_SECRET=VERY_SECURE_SECRET
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
28
.github/dependabot.yml
vendored
28
.github/dependabot.yml
vendored
|
|
@ -1,14 +1,32 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
# Monitor npm dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
# Enable version updates for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
versioning-strategy: auto
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "npm"
|
||||
|
||||
# Monitor Docker dependencies
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
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"
|
||||
7
.github/workflows/build.yml
vendored
7
.github/workflows/build.yml
vendored
|
|
@ -36,9 +36,10 @@ jobs:
|
|||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/marcus7i/ulinkshortener
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,format=short
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
|
|
@ -50,5 +51,5 @@ jobs:
|
|||
file: ./Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64, linux/arm64/v8
|
||||
tags: ghcr.io/marcus7i/ulinkshortener:latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
46
.gitignore
vendored
46
.gitignore
vendored
|
|
@ -1,2 +1,44 @@
|
|||
venv/
|
||||
.env
|
||||
# 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
|
||||
27
Dockerfile
27
Dockerfile
|
|
@ -1,16 +1,23 @@
|
|||
FROM golang:1.21-alpine AS builder
|
||||
FROM oven/bun:1 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ulinkshortener ./cmd/api
|
||||
FROM alpine:latest
|
||||
|
||||
RUN bun run build
|
||||
|
||||
FROM oven/bun:1-slim AS runner
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk --no-cache add ca-certificates
|
||||
COPY --from=builder /app/ulinkshortener .
|
||||
COPY --from=builder /app/web /app/web
|
||||
ENV NODE_ENV=production
|
||||
|
||||
EXPOSE ${PORT}
|
||||
CMD ["./ulinkshortener"]
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||
|
||||
RUN bun install --production
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
7
LICENSE
7
LICENSE
|
|
@ -1,7 +0,0 @@
|
|||
Copyright 2025 MarcUs7i
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
17
README.md
17
README.md
|
|
@ -1,9 +1,9 @@
|
|||
# uLinkShortener
|
||||
# µLinkShortener v2
|
||||
|
||||
This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a custom URL shortener. It uses Go, MongoDB, and Docker for quick deployment.
|
||||
This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a custom URL shortener. It uses Next.JS, MongoDB, and Docker for quick deployment.
|
||||
|
||||
## Prerequisites
|
||||
- Go
|
||||
- Next.js
|
||||
- MongoDB database (local or remote)
|
||||
- Docker & Docker Compose (optional, for containerized deployments)
|
||||
|
||||
|
|
@ -12,7 +12,9 @@ This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a cust
|
|||
4. Define environment variables in the `.env` file:
|
||||
```
|
||||
MONGO_URI=mongodb://<username>:<password>@<host>:<port>/<database>
|
||||
PORT=<desired_port>
|
||||
MONGO_DB_NAME=<database>
|
||||
NEXTAUTH_SECRET=VERY_SECURE_SECRET
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
## Running Locally
|
||||
|
|
@ -21,11 +23,12 @@ This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a cust
|
|||
|
||||
1. Install dependencies:
|
||||
```
|
||||
go mod download
|
||||
bun install
|
||||
```
|
||||
2. Build and run:
|
||||
```
|
||||
go run cmd/api/main.go
|
||||
bun run build
|
||||
bun run start
|
||||
```
|
||||
|
||||
### With Docker
|
||||
|
|
@ -44,7 +47,7 @@ This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a cust
|
|||
```
|
||||
docker-compose up --build
|
||||
```
|
||||
2. The application will be available at http://localhost:5000
|
||||
2. The application will be available at http://localhost:3000
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
998
bun.lock
Normal file
998
bun.lock
Normal file
|
|
@ -0,0 +1,998 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ulinkshortener",
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.0",
|
||||
"mongodb": "^6.17.0",
|
||||
"next": "15.3.3",
|
||||
"next-auth": "^4.24.11",
|
||||
"react": "^19.0.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"ua-parser-js": "^2.0.3",
|
||||
"winston": "^3.17.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="],
|
||||
|
||||
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
|
||||
|
||||
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.3", "", { "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.20.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.2.3", "", {}, "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.28.0", "", {}, "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.2", "", { "dependencies": { "@eslint/core": "^0.15.0", "levn": "^0.4.1" } }, "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.1.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.1.0" }, "os": "darwin", "cpu": "x64" }, "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.1.0" }, "os": "linux", "cpu": "arm" }, "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.1.0" }, "os": "linux", "cpu": "s390x" }, "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.2", "", { "dependencies": { "@emnapi/runtime": "^1.4.3" }, "cpu": "none" }, "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw=="],
|
||||
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
|
||||
"@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/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.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" } }, "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA=="],
|
||||
|
||||
"@next/env": ["@next/env@15.3.3", "", {}, "sha512-OdiMrzCl2Xi0VTjiQQUK0Xh7bJHnOuET2s+3V+Y40WJBAXrJeGA3f+I8MZJ/YQ3mVGi5XGR1L66oFlgqXhQ4Vw=="],
|
||||
|
||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.3.3", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-VKZJEiEdpKkfBmcokGjHu0vGDG+8CehGs90tBEy/IDoDDKGngeyIStt2MmE5FYNyU9BhgR7tybNWTAJY/30u+Q=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.11.0", "", {}, "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ=="],
|
||||
|
||||
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.10", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.10" } }, "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.10", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.10", "@tailwindcss/oxide-darwin-arm64": "4.1.10", "@tailwindcss/oxide-darwin-x64": "4.1.10", "@tailwindcss/oxide-freebsd-x64": "4.1.10", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", "@tailwindcss/oxide-linux-x64-musl": "4.1.10", "@tailwindcss/oxide-wasm32-wasi": "4.1.10", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" } }, "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.10", "", { "os": "android", "cpu": "arm64" }, "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.10", "", { "os": "linux", "cpu": "arm" }, "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.10", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.10", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.10", "", { "os": "win32", "cpu": "x64" }, "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA=="],
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.10", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.10", "@tailwindcss/oxide": "4.1.10", "postcss": "^8.4.41", "tailwindcss": "4.1.10" } }, "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q=="],
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="],
|
||||
|
||||
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
|
||||
|
||||
"@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="],
|
||||
|
||||
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
|
||||
|
||||
"@types/whatwg-url": ["@types/whatwg-url@11.0.5", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.34.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/type-utils": "8.34.0", "@typescript-eslint/utils": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.34.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.34.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", "@typescript-eslint/typescript-estree": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.0", "@typescript-eslint/types": "^8.34.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.0", "", { "dependencies": { "@typescript-eslint/types": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0" } }, "sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-+W9VYHKFIzA5cBeooqQxqNriAP0QeQ7xTiDuIOr71hzgffm3EL2hxwWBIIj4GuofIbKxGNarpKqIq6Q6YrShOA=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.34.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.34.0", "@typescript-eslint/utils": "8.34.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-n7zSmOcUVhcRYC75W2pnPpbO1iwhJY3NLoHEtbJwJSNlVAZuwqu05zY3f3s2SDWWDSo9FdN5szqc73DCtDObAg=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.34.0", "", {}, "sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.0", "@typescript-eslint/tsconfig-utils": "8.34.0", "@typescript-eslint/types": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rOi4KZxI7E0+BMqG7emPSK1bB4RICCpF7QD3KCLXn9ZvWoESsOMlHyZPAHyG04ujVplPaHbmEvs34m+wjgtVtg=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.34.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", "@typescript-eslint/typescript-estree": "8.34.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.0", "", { "dependencies": { "@typescript-eslint/types": "8.34.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA=="],
|
||||
|
||||
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.9.0", "", { "os": "android", "cpu": "arm" }, "sha512-h1T2c2Di49ekF2TE8ZCoJkb+jwETKUIPDJ/nO3tJBKlLFPu+fyd93f0rGP/BvArKx2k2HlRM4kqkNarj3dvZlg=="],
|
||||
|
||||
"@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.9.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sG1NHtgXtX8owEkJ11yn34vt0Xqzi3k9TJ8zppDmyG8GZV4kVWw44FHwKwHeEFl07uKPeC4ZoyuQaGh5ruJYPA=="],
|
||||
|
||||
"@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.9.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nJ9z47kfFnCxN1z/oYZS7HSNsFh43y2asePzTEZpEvK7kGyuShSl3RRXnm/1QaqFL+iP+BjMwuB+DYUymOkA5A=="],
|
||||
|
||||
"@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.9.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-TK+UA1TTa0qS53rjWn7cVlEKVGz2B6JYe0C++TdQjvWYIyx83ruwh0wd4LRxYBM5HeuAzXcylA9BH2trARXJTw=="],
|
||||
|
||||
"@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.9.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6uZwzMRFcD7CcCd0vz3Hp+9qIL2jseE/bx3ZjaLwn8t714nYGwiE84WpaMCYjU+IQET8Vu/+BNAGtYD7BG/0yA=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.9.0", "", { "os": "linux", "cpu": "arm" }, "sha512-bPUBksQfrgcfv2+mm+AZinaKq8LCFvt5PThYqRotqSuuZK1TVKkhbVMS/jvSRfYl7jr3AoZLYbDkItxgqMKRkg=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.9.0", "", { "os": "linux", "cpu": "arm" }, "sha512-uT6E7UBIrTdCsFQ+y0tQd3g5oudmrS/hds5pbU3h4s2t/1vsGWbbSKhBSCD9mcqaqkBwoqlECpUrRJCmldl8PA=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.9.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.9.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.9.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.9.0", "", { "os": "linux", "cpu": "none" }, "sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.9.0", "", { "os": "linux", "cpu": "none" }, "sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.9.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.9.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA=="],
|
||||
|
||||
"@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.9.0", "", { "os": "linux", "cpu": "x64" }, "sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg=="],
|
||||
|
||||
"@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.9.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ=="],
|
||||
|
||||
"@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.9.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rknkrTRuvujprrbPmGeHi8wYWxmNVlBoNW8+4XF2hXUnASOjmuC9FNF1tGbDiRQWn264q9U/oGtixyO3BT8adQ=="],
|
||||
|
||||
"@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.9.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Ceymm+iBl+bgAICtgiHyMLz6hjxmLJKqBim8tDzpX61wpZOx2bPK6Gjuor7I2RiUynVjvvkoRIkrPyMwzBzF3A=="],
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.9.0", "", { "os": "win32", "cpu": "x64" }, "sha512-k59o9ZyeyS0hAlcaKFezYSH2agQeRFEB7KoQLXl3Nb3rgkqT1NY9Vwy+SqODiLmYnEjxWJVRE/yq2jFVqdIxZw=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||
|
||||
"array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
|
||||
|
||||
"array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="],
|
||||
|
||||
"array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="],
|
||||
|
||||
"array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="],
|
||||
|
||||
"array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="],
|
||||
|
||||
"array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="],
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
"axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="],
|
||||
|
||||
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001723", "", {}, "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"chart.js": ["chart.js@4.5.0", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ=="],
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||
|
||||
"colorspace": ["colorspace@1.1.4", "", { "dependencies": { "color": "^3.1.3", "text-hex": "1.0.x" } }, "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
|
||||
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
|
||||
|
||||
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
|
||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"detect-europe-js": ["detect-europe-js@0.1.2", "", {}, "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="],
|
||||
|
||||
"es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-iterator-helpers": ["es-iterator-helpers@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.0.3", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.6", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.4", "safe-array-concat": "^1.1.3" } }, "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="],
|
||||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.28.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.28.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ=="],
|
||||
|
||||
"eslint-config-next": ["eslint-config-next@15.3.3", "", { "dependencies": { "@next/eslint-plugin-next": "15.3.3", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-QJLv/Ouk2vZnxL4b67njJwTLjTf7uZRltI0LL4GERYR4qMF5z08+gxkfODAeaK7TiC6o+cER91bDaEnwrTWV6Q=="],
|
||||
|
||||
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
|
||||
|
||||
"eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="],
|
||||
|
||||
"eslint-module-utils": ["eslint-module-utils@2.12.0", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg=="],
|
||||
|
||||
"eslint-plugin-import": ["eslint-plugin-import@2.31.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", "array.prototype.findlastindex": "^1.2.5", "array.prototype.flat": "^1.3.2", "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.0", "hasown": "^2.0.2", "is-core-module": "^2.15.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.0", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.8", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A=="],
|
||||
|
||||
"eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
|
||||
|
||||
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||
|
||||
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||
|
||||
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"form-data": ["form-data@4.0.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
|
||||
|
||||
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||
|
||||
"has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="],
|
||||
|
||||
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
||||
|
||||
"is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
|
||||
|
||||
"is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
|
||||
|
||||
"is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="],
|
||||
|
||||
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="],
|
||||
|
||||
"is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
|
||||
|
||||
"is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
|
||||
|
||||
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
|
||||
"is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
|
||||
|
||||
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
|
||||
|
||||
"is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="],
|
||||
|
||||
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
|
||||
|
||||
"is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
|
||||
|
||||
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
|
||||
|
||||
"is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
|
||||
|
||||
"is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
|
||||
|
||||
"is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
|
||||
|
||||
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
|
||||
|
||||
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
|
||||
|
||||
"jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
|
||||
|
||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="],
|
||||
|
||||
"language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
|
||||
|
||||
"language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
||||
|
||||
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
|
||||
|
||||
"mongodb": ["mongodb@6.17.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.1.9", "bson": "^6.10.4", "mongodb-connection-string-url": "^3.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", "snappy": "^7.2.2", "socks": "^2.7.1" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA=="],
|
||||
|
||||
"mongodb-connection-string-url": ["mongodb-connection-string-url@3.0.2", "", { "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" } }, "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"napi-postinstall": ["napi-postinstall@0.2.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"next": ["next@15.3.3", "", { "dependencies": { "@next/env": "15.3.3", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.3", "@next/swc-darwin-x64": "15.3.3", "@next/swc-linux-arm64-gnu": "15.3.3", "@next/swc-linux-arm64-musl": "15.3.3", "@next/swc-linux-x64-gnu": "15.3.3", "@next/swc-linux-x64-musl": "15.3.3", "@next/swc-win32-arm64-msvc": "15.3.3", "@next/swc-win32-x64-msvc": "15.3.3", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "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-JqNj29hHNmCLtNvd090SyRbXJiivQ+58XjCcrC50Crb5g5u2zi7Y2YivbsEfzk6AtVI80akdOQbaMZwWB1Hthw=="],
|
||||
|
||||
"next-auth": ["next-auth@4.24.11", "", { "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.2", "next": "^12.2.5 || ^13 || ^14 || ^15", "nodemailer": "^6.6.5", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"oauth": ["oauth@0.9.15", "", {}, "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
||||
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
|
||||
|
||||
"object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="],
|
||||
|
||||
"object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="],
|
||||
|
||||
"object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="],
|
||||
|
||||
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
|
||||
"react-chartjs-2": ["react-chartjs-2@5.3.0", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw=="],
|
||||
|
||||
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
||||
|
||||
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
||||
|
||||
"sharp": ["sharp@0.34.2", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.2", "@img/sharp-darwin-x64": "0.34.2", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.2", "@img/sharp-linux-arm64": "0.34.2", "@img/sharp-linux-s390x": "0.34.2", "@img/sharp-linux-x64": "0.34.2", "@img/sharp-linuxmusl-arm64": "0.34.2", "@img/sharp-linuxmusl-x64": "0.34.2", "@img/sharp-wasm32": "0.34.2", "@img/sharp-win32-arm64": "0.34.2", "@img/sharp-win32-ia32": "0.34.2", "@img/sharp-win32-x64": "0.34.2" } }, "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
||||
|
||||
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
||||
|
||||
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
||||
|
||||
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
|
||||
|
||||
"string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="],
|
||||
|
||||
"string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="],
|
||||
|
||||
"string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="],
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.10", "", {}, "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA=="],
|
||||
|
||||
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
|
||||
|
||||
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||
|
||||
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
|
||||
|
||||
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||
|
||||
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
|
||||
|
||||
"typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="],
|
||||
|
||||
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="],
|
||||
|
||||
"ua-parser-js": ["ua-parser-js@2.0.3", "", { "dependencies": { "@types/node-fetch": "^2.6.12", "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "node-fetch": "^2.7.0", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LZyXZdNttONW8LjzEH3Z8+6TE7RfrEiJqDKyh0R11p/kxvrV2o9DrT2FGZO+KVNs3k+drcIQ6C3En6wLnzJGpw=="],
|
||||
|
||||
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unrs-resolver": ["unrs-resolver@1.9.0", "", { "dependencies": { "napi-postinstall": "^0.2.2" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.9.0", "@unrs/resolver-binding-android-arm64": "1.9.0", "@unrs/resolver-binding-darwin-arm64": "1.9.0", "@unrs/resolver-binding-darwin-x64": "1.9.0", "@unrs/resolver-binding-freebsd-x64": "1.9.0", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.0", "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.0", "@unrs/resolver-binding-linux-arm64-gnu": "1.9.0", "@unrs/resolver-binding-linux-arm64-musl": "1.9.0", "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.0", "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.0", "@unrs/resolver-binding-linux-riscv64-musl": "1.9.0", "@unrs/resolver-binding-linux-s390x-gnu": "1.9.0", "@unrs/resolver-binding-linux-x64-gnu": "1.9.0", "@unrs/resolver-binding-linux-x64-musl": "1.9.0", "@unrs/resolver-binding-wasm32-wasi": "1.9.0", "@unrs/resolver-binding-win32-arm64-msvc": "1.9.0", "@unrs/resolver-binding-win32-ia32-msvc": "1.9.0", "@unrs/resolver-binding-win32-x64-msvc": "1.9.0" } }, "sha512-wqaRu4UnzBD2ABTC1kLfBjAqIDZ5YUTr/MLGa7By47JV1bJDSW7jq/ZSLigB7enLe7ubNaJhtnBXgrc/50cEhg=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
"which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
|
||||
|
||||
"which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
|
||||
|
||||
"winston": ["winston@3.17.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "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-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw=="],
|
||||
|
||||
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" }, "bundled": true }, "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"colorspace/color": ["color@3.2.1", "", { "dependencies": { "color-convert": "^1.9.3", "color-string": "^1.6.0" } }, "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA=="],
|
||||
|
||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"colorspace/color/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||
|
||||
"node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"colorspace/color/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/marcus7i/ulinkshortener/internal/api"
|
||||
"github.com/marcus7i/ulinkshortener/internal/config"
|
||||
"github.com/marcus7i/ulinkshortener/internal/database"
|
||||
"github.com/marcus7i/ulinkshortener/internal/api/handlers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("No .env file found, using environment variables")
|
||||
}
|
||||
|
||||
cfg := config.New()
|
||||
|
||||
db, err := database.New(cfg.MongoURI)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to MongoDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
r := api.SetupRouter(db)
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"marshal": func(v interface{}) template.JS {
|
||||
a, _ := json.Marshal(v)
|
||||
return template.JS(a)
|
||||
},
|
||||
}
|
||||
|
||||
templates := template.Must(template.New("").Funcs(funcMap).ParseGlob("web/templates/*.html"))
|
||||
|
||||
r.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
h := handlers.NewHandler(db)
|
||||
stats, err := h.GetStatsData(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Error generating stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cookie, _ := r.Cookie("account_id")
|
||||
if cookie != nil {
|
||||
stats.LoggedIn = true
|
||||
}
|
||||
|
||||
templates.ExecuteTemplate(w, "index.html", map[string]interface{}{
|
||||
"stats": stats,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("Server running on port %s", cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Listen error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatal("Server forced to shutdown:", err)
|
||||
}
|
||||
|
||||
log.Println("Server exited with code 0")
|
||||
}
|
||||
|
|
@ -3,8 +3,13 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: ulinkshortener
|
||||
restart: unless-stopped
|
||||
network_mode: "host"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- MONGO_URI=${MONGO_URI}
|
||||
- MONGO_DB_NAME=${MONGO_DB_NAME}
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
volumes:
|
||||
- ./.env:/app/.env
|
||||
- ./.env:/app/.env
|
||||
restart: unless-stopped
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
services:
|
||||
ulinkshortener:
|
||||
image: ghcr.io/marcus7i/ulinkshortener:latest
|
||||
container_name: ulinkshortener
|
||||
restart: unless-stopped
|
||||
network_mode: "host"
|
||||
image: ghcr.io/kizuren/ulinkshortener
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- MONGO_URI=${MONGO_URI}
|
||||
- MONGO_DB_NAME=${MONGO_DB_NAME}
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
volumes:
|
||||
- ./.env:/app/.env
|
||||
- ./.env:/app/.env
|
||||
restart: unless-stopped
|
||||
16
eslint.config.mjs
Normal file
16
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
23
go.mod
23
go.mod
|
|
@ -1,23 +0,0 @@
|
|||
module github.com/marcus7i/ulinkshortener
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mssola/user_agent v0.6.0
|
||||
go.mongodb.org/mongo-driver v1.17.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/klauspost/compress v1.17.5 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
56
go.sum
56
go.sum
|
|
@ -1,56 +0,0 @@
|
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.17.5 h1:d4vBd+7CHydUqpFBgUEKkSdtSugf9YFmSkvUYPquI5E=
|
||||
github.com/klauspost/compress v1.17.5/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/mssola/user_agent v0.6.0 h1:uwPR4rtWlCHRFyyP9u2KOV0u8iQXmS7Z7feTrstQwk4=
|
||||
github.com/mssola/user_agent v0.6.0/go.mod h1:TTPno8LPY3wAIEKRpAtkdMT0f8SE24pLRGPahjCH4uw=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/marcus7i/ulinkshortener/internal/database"
|
||||
"github.com/marcus7i/ulinkshortener/internal/models"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
)
|
||||
|
||||
func (h *Handler) GetAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
accountID := vars["accountID"]
|
||||
|
||||
ctx := context.Background()
|
||||
var user models.User
|
||||
err := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": accountID}).Decode(&user)
|
||||
if err != nil {
|
||||
respondWithError(w, http.StatusUnauthorized, "Invalid account")
|
||||
return
|
||||
}
|
||||
|
||||
cursor, err := h.DB.Collection(database.LinksCollection).Find(ctx, bson.M{"account_id": accountID})
|
||||
if err != nil {
|
||||
respondWithError(w, http.StatusInternalServerError, "Failed to retrieve links")
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var links []models.Link
|
||||
if err = cursor.All(ctx, &links); err != nil {
|
||||
respondWithError(w, http.StatusInternalServerError, "Failed to process links")
|
||||
return
|
||||
}
|
||||
|
||||
cursor, err = h.DB.Collection(database.AnalyticsCollection).Find(ctx, bson.M{"account_id": accountID})
|
||||
if err != nil {
|
||||
respondWithError(w, http.StatusInternalServerError, "Failed to retrieve analytics")
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var analytics []map[string]interface{}
|
||||
if err = cursor.All(ctx, &analytics); err != nil {
|
||||
respondWithError(w, http.StatusInternalServerError, "Failed to process analytics")
|
||||
return
|
||||
}
|
||||
|
||||
respondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"links": links,
|
||||
"analytics": analytics,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GetStatsData(ctx context.Context) (models.Stats, error) {
|
||||
|
||||
totalLinks, err := h.DB.Collection(database.LinksCollection).CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
totalLinks = 0
|
||||
}
|
||||
|
||||
totalClicks, err := h.DB.Collection(database.AnalyticsCollection).CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
totalClicks = 0
|
||||
}
|
||||
|
||||
ipVersionsPipeline := mongo.Pipeline{
|
||||
{{Key: "$group", Value: bson.D{{Key: "_id", Value: "$ip_version"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}},
|
||||
{{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}},
|
||||
}
|
||||
ipVersionsCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, ipVersionsPipeline)
|
||||
var ipVersions []models.StatItem
|
||||
if err == nil {
|
||||
ipVersionsCursor.All(ctx, &ipVersions)
|
||||
}
|
||||
|
||||
osPipeline := mongo.Pipeline{
|
||||
{{Key: "$group", Value: bson.D{{Key: "_id", Value: "$platform"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}},
|
||||
{{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}},
|
||||
{{Key: "$limit", Value: 10}},
|
||||
}
|
||||
osCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, osPipeline)
|
||||
var osStats []models.StatItem
|
||||
if err == nil {
|
||||
osCursor.All(ctx, &osStats)
|
||||
}
|
||||
|
||||
countryPipeline := mongo.Pipeline{
|
||||
{{Key: "$group", Value: bson.D{{Key: "_id", Value: "$country"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}},
|
||||
{{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}},
|
||||
{{Key: "$limit", Value: 10}},
|
||||
}
|
||||
countryCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, countryPipeline)
|
||||
var countryStats []models.StatItem
|
||||
if err == nil {
|
||||
countryCursor.All(ctx, &countryStats)
|
||||
}
|
||||
|
||||
ispPipeline := mongo.Pipeline{
|
||||
{{Key: "$group", Value: bson.D{{Key: "_id", Value: "$isp"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}},
|
||||
{{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}},
|
||||
{{Key: "$limit", Value: 10}},
|
||||
}
|
||||
ispCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, ispPipeline)
|
||||
var ispStats []models.StatItem
|
||||
if err == nil {
|
||||
ispCursor.All(ctx, &ispStats)
|
||||
}
|
||||
|
||||
stats := models.Stats{
|
||||
TotalLinks: totalLinks,
|
||||
TotalClicks: totalClicks,
|
||||
ChartData: models.ChartData{
|
||||
IPVersions: ipVersions,
|
||||
OSStats: osStats,
|
||||
CountryStats: countryStats,
|
||||
ISPStats: ispStats,
|
||||
},
|
||||
LoggedIn: false,
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/marcus7i/ulinkshortener/internal/database"
|
||||
"github.com/marcus7i/ulinkshortener/internal/models"
|
||||
"github.com/marcus7i/ulinkshortener/internal/utils"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
AccountID string `json:"account_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.Background()
|
||||
accountID := utils.GenerateAccountID()
|
||||
|
||||
for {
|
||||
var user models.User
|
||||
err := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": accountID}).Decode(&user)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
accountID = utils.GenerateAccountID()
|
||||
}
|
||||
|
||||
_, err := h.DB.Collection(database.UsersCollection).InsertOne(ctx, models.User{
|
||||
AccountID: accountID,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
respondWithError(w, http.StatusInternalServerError, "Failed to create account")
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "account_id",
|
||||
Value: accountID,
|
||||
Path: "/",
|
||||
MaxAge: 31536000, // 1 year
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
respondWithJSON(w, http.StatusOK, map[string]string{"account_id": accountID})
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondWithError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var user models.User
|
||||
err := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": req.AccountID}).Decode(&user)
|
||||
if err != nil {
|
||||
respondWithError(w, http.StatusUnauthorized, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "account_id",
|
||||
Value: req.AccountID,
|
||||
Path: "/",
|
||||
MaxAge: 31536000, // 1 year
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
respondWithJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "account_id",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
respondWithJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/marcus7i/ulinkshortener/internal/database"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
DB *database.MongoDB
|
||||
}
|
||||
|
||||
func NewHandler(db *database.MongoDB) *Handler {
|
||||
return &Handler{
|
||||
DB: db,
|
||||
}
|
||||
}
|
||||
|
||||
func respondWithError(w http.ResponseWriter, code int, message string) {
|
||||
respondWithJSON(w, code, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) {
|
||||
response, _ := json.Marshal(payload)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
w.Write(response)
|
||||
}
|
||||
|
||||
func getAccountIDFromCookie(r *http.Request) string {
|
||||
cookie, err := r.Cookie("account_id")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/marcus7i/ulinkshortener/internal/database"
|
||||
"github.com/marcus7i/ulinkshortener/internal/models"
|
||||
"github.com/marcus7i/ulinkshortener/internal/utils"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
)
|
||||
|
||||
type CreateLinkRequest struct {
|
||||
AccountID string `json:"account_id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type CreateLinkResponse struct {
|
||||
ShortURL string `json:"short_url"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateLink(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateLinkRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondWithError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
user := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": req.AccountID})
|
||||
if user.Err() != nil {
|
||||
respondWithError(w, http.StatusUnauthorized, "Invalid account")
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.IsValidURL(req.URL) {
|
||||
respondWithError(w, http.StatusBadRequest, "Invalid URL. Please provide a valid URL with scheme (e.g., http:// or https://)")
|
||||
return
|
||||
}
|
||||
|
||||
shortID := utils.GenerateShortID()
|
||||
|
||||
link := models.Link{
|
||||
ShortID: shortID,
|
||||
TargetURL: req.URL,
|
||||
AccountID: req.AccountID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err := h.DB.Collection(database.LinksCollection).InsertOne(ctx, link)
|
||||
if err != nil {
|
||||
respondWithError(w, http.StatusInternalServerError, "Failed to create link")
|
||||
return
|
||||
}
|
||||
|
||||
respondWithJSON(w, http.StatusOK, CreateLinkResponse{
|
||||
ShortURL: fmt.Sprintf("/l/%s", shortID),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) RedirectLink(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
shortID := vars["shortID"]
|
||||
|
||||
ctx := context.Background()
|
||||
var link models.Link
|
||||
err := h.DB.Collection(database.LinksCollection).FindOne(ctx, bson.M{"short_id": shortID}).Decode(&link)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
http.Error(w, "Link not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
clientInfo := utils.GetClientInfo(r)
|
||||
clientInfo["link_id"] = shortID
|
||||
clientInfo["account_id"] = link.AccountID
|
||||
|
||||
_, err = h.DB.Collection(database.AnalyticsCollection).InsertOne(ctx, clientInfo)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to log analytics: %v\n", err)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, link.TargetURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteLink(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
shortID := vars["shortID"]
|
||||
accountID := getAccountIDFromCookie(r)
|
||||
|
||||
if accountID == "" {
|
||||
respondWithError(w, http.StatusUnauthorized, "Not logged in")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var link models.Link
|
||||
err := h.DB.Collection(database.LinksCollection).FindOne(ctx, bson.M{
|
||||
"short_id": shortID,
|
||||
"account_id": accountID,
|
||||
}).Decode(&link)
|
||||
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
respondWithError(w, http.StatusNotFound, "Link not found or unauthorized")
|
||||
} else {
|
||||
respondWithError(w, http.StatusInternalServerError, "Server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.DB.Collection(database.LinksCollection).DeleteOne(ctx, bson.M{"short_id": shortID})
|
||||
if err != nil {
|
||||
respondWithError(w, http.StatusInternalServerError, "Failed to delete link")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.DB.Collection(database.AnalyticsCollection).DeleteMany(ctx, bson.M{"link_id": shortID})
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to delete analytics: %v\n", err)
|
||||
}
|
||||
|
||||
respondWithJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/marcus7i/ulinkshortener/internal/api/handlers"
|
||||
"github.com/marcus7i/ulinkshortener/internal/database"
|
||||
)
|
||||
|
||||
func SetupRouter(db *database.MongoDB) *mux.Router {
|
||||
r := mux.NewRouter()
|
||||
h := handlers.NewHandler(db)
|
||||
|
||||
fs := http.FileServer(http.Dir("./web/static"))
|
||||
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fs))
|
||||
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/static/css/style.css" {
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
} else if r.URL.Path == "/static/js/script.js" {
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
|
||||
r.HandleFunc("/register", h.Register).Methods("POST")
|
||||
r.HandleFunc("/login", h.Login).Methods("POST")
|
||||
r.HandleFunc("/logout", h.Logout).Methods("POST")
|
||||
|
||||
r.HandleFunc("/create", h.CreateLink).Methods("POST")
|
||||
r.HandleFunc("/l/{shortID}", h.RedirectLink).Methods("GET")
|
||||
r.HandleFunc("/analytics/{accountID}", h.GetAnalytics).Methods("GET")
|
||||
r.HandleFunc("/delete/{shortID}", h.DeleteLink).Methods("DELETE")
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MongoURI string
|
||||
Port string
|
||||
}
|
||||
|
||||
func New() *Config {
|
||||
godotenv.Load()
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "5000"
|
||||
}
|
||||
|
||||
return &Config{
|
||||
MongoURI: os.Getenv("MONGO_URI"),
|
||||
Port: port,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
type MongoDB struct {
|
||||
Client *mongo.Client
|
||||
DB *mongo.Database
|
||||
}
|
||||
|
||||
const (
|
||||
LinksCollection = "links"
|
||||
AnalyticsCollection = "analytics"
|
||||
UsersCollection = "users"
|
||||
DefaultDBName = "uLinkShortener"
|
||||
)
|
||||
|
||||
func New(uri string) (*MongoDB, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = client.Ping(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dbName := getDatabaseName(uri)
|
||||
if dbName == "" {
|
||||
dbName = DefaultDBName
|
||||
}
|
||||
db := client.Database(dbName)
|
||||
|
||||
return &MongoDB{
|
||||
Client: client,
|
||||
DB: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getDatabaseName(uri string) string {
|
||||
lastSlashIndex := strings.LastIndex(uri, "/")
|
||||
if lastSlashIndex == -1 || lastSlashIndex == len(uri)-1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
dbName := uri[lastSlashIndex+1:]
|
||||
|
||||
if queryIndex := strings.Index(dbName, "?"); queryIndex != -1 {
|
||||
dbName = dbName[:queryIndex]
|
||||
}
|
||||
|
||||
return dbName
|
||||
}
|
||||
|
||||
func (m *MongoDB) Close() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return m.Client.Disconnect(ctx)
|
||||
}
|
||||
|
||||
func (m *MongoDB) Collection(name string) *mongo.Collection {
|
||||
return m.DB.Collection(name)
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Analytics struct {
|
||||
LinkID string `json:"link_id" bson:"link_id"`
|
||||
AccountID string `json:"account_id" bson:"account_id"`
|
||||
IP string `json:"ip" bson:"ip"`
|
||||
UserAgent string `json:"user_agent" bson:"user_agent"`
|
||||
Platform string `json:"platform" bson:"platform"`
|
||||
Browser string `json:"browser" bson:"browser"`
|
||||
Version string `json:"version" bson:"version"`
|
||||
Language string `json:"language" bson:"language"`
|
||||
Referrer string `json:"referrer" bson:"referrer"`
|
||||
Timestamp time.Time `json:"timestamp" bson:"timestamp"`
|
||||
RemotePort string `json:"remote_port" bson:"remote_port"`
|
||||
Accept string `json:"accept" bson:"accept"`
|
||||
AcceptLanguage string `json:"accept_language" bson:"accept_language"`
|
||||
AcceptEncoding string `json:"accept_encoding" bson:"accept_encoding"`
|
||||
ScreenSize string `json:"screen_size" bson:"screen_size"`
|
||||
WindowSize string `json:"window_size" bson:"window_size"`
|
||||
Country string `json:"country" bson:"country"`
|
||||
ISP string `json:"isp" bson:"isp"`
|
||||
IPVersion string `json:"ip_version" bson:"ip_version"`
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
TotalLinks int64 `json:"total_links"`
|
||||
TotalClicks int64 `json:"total_clicks"`
|
||||
ChartData ChartData `json:"chart_data"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
}
|
||||
|
||||
type ChartData struct {
|
||||
IPVersions []StatItem `json:"ip_versions"`
|
||||
OSStats []StatItem `json:"os_stats"`
|
||||
CountryStats []StatItem `json:"country_stats"`
|
||||
ISPStats []StatItem `json:"isp_stats"`
|
||||
}
|
||||
|
||||
type StatItem struct {
|
||||
ID string `json:"_id" bson:"_id"`
|
||||
Count int `json:"count" bson:"count"`
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Link struct {
|
||||
ShortID string `json:"short_id" bson:"short_id"`
|
||||
TargetURL string `json:"target_url" bson:"target_url"`
|
||||
AccountID string `json:"account_id" bson:"account_id"`
|
||||
CreatedAt time.Time `json:"created_at" bson:"created_at"`
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
AccountID string `json:"account_id" bson:"account_id"`
|
||||
CreatedAt time.Time `json:"created_at" bson:"created_at"`
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mssola/user_agent"
|
||||
)
|
||||
|
||||
func GetClientInfo(r *http.Request) map[string]interface{} {
|
||||
ua := user_agent.New(r.UserAgent())
|
||||
|
||||
ipAddress := r.Header.Get("CF-Connecting-IP")
|
||||
if ipAddress == "" {
|
||||
ipAddress = r.Header.Get("X-Real-IP")
|
||||
}
|
||||
if ipAddress == "" {
|
||||
forwardedFor := r.Header.Get("X-Forwarded-For")
|
||||
if forwardedFor != "" {
|
||||
ips := strings.Split(forwardedFor, ",")
|
||||
ipAddress = strings.TrimSpace(ips[0])
|
||||
}
|
||||
}
|
||||
if ipAddress == "" {
|
||||
ipAddress = r.RemoteAddr
|
||||
if colonIndex := strings.LastIndex(ipAddress, ":"); colonIndex != -1 {
|
||||
ipAddress = ipAddress[:colonIndex]
|
||||
}
|
||||
}
|
||||
if ipAddress == "" {
|
||||
ipAddress = "Unknown"
|
||||
}
|
||||
|
||||
browser, version := ua.Browser()
|
||||
|
||||
platform := r.Header.Get("sec-ch-ua-platform")
|
||||
if platform == "" {
|
||||
platform = ua.OS()
|
||||
if platform == "" {
|
||||
platform = "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
language := r.Header.Get("Accept-Language")
|
||||
if language != "" && strings.Contains(language, ",") {
|
||||
language = strings.Split(language, ",")[0]
|
||||
} else if language == "" {
|
||||
language = "Unknown"
|
||||
}
|
||||
|
||||
ipVersion := "IPv4"
|
||||
if strings.Contains(ipAddress, ":") {
|
||||
ipVersion = "IPv6"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"ip": ipAddress,
|
||||
"user_agent": r.UserAgent(),
|
||||
"platform": platform,
|
||||
"browser": browser,
|
||||
"version": version,
|
||||
"language": language,
|
||||
"referrer": valueOrDefault(r.Referer(), "Direct"),
|
||||
"timestamp": time.Now(),
|
||||
"remote_port": valueOrDefault(r.Header.Get("X-Forwarded-Port"), "Unknown"),
|
||||
"accept": valueOrDefault(r.Header.Get("Accept"), "Unknown"),
|
||||
"accept_language": valueOrDefault(r.Header.Get("Accept-Language"), "Unknown"),
|
||||
"accept_encoding": valueOrDefault(r.Header.Get("Accept-Encoding"), "Unknown"),
|
||||
"screen_size": valueOrDefault(r.Header.Get("Sec-CH-UA-Platform-Screen"), "Unknown"),
|
||||
"window_size": valueOrDefault(r.Header.Get("Viewport-Width"), "Unknown"),
|
||||
"country": valueOrDefault(r.Header.Get("CF-IPCountry"), "Unknown"),
|
||||
"isp": valueOrDefault(r.Header.Get("X-ISP"), "Unknown"),
|
||||
"ip_version": ipVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrDefault(value, defaultValue string) string {
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
idLength = 8
|
||||
accountLength = 8
|
||||
)
|
||||
|
||||
var seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
func GenerateShortID() string {
|
||||
return randomString(idLength, letterBytes)
|
||||
}
|
||||
|
||||
func GenerateAccountID() string {
|
||||
return randomString(accountLength, "0123456789")
|
||||
}
|
||||
|
||||
func randomString(length int, charset string) string {
|
||||
b := strings.Builder{}
|
||||
b.Grow(length)
|
||||
for i := 0; i < length; i++ {
|
||||
b.WriteByte(charset[seededRand.Intn(len(charset))])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func IsValidURL(urlStr string) bool {
|
||||
if strings.TrimSpace(urlStr) == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return parsedURL.Scheme != "" && parsedURL.Host != ""
|
||||
}
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ['localhost', '*.marcus7i.net'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
34
package.json
Normal file
34
package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "ulinkshortener",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.0",
|
||||
"mongodb": "^6.17.0",
|
||||
"next": "15.3.3",
|
||||
"next-auth": "^4.24.11",
|
||||
"react": "^19.0.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"ua-parser-js": "^2.0.3",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
168
src/app/Home.module.css
Normal file
168
src/app/Home.module.css
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/* 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;
|
||||
}
|
||||
}
|
||||
228
src/app/admin/AdminDashboard.module.css
Normal file
228
src/app/admin/AdminDashboard.module.css
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
278
src/app/admin/page.tsx
Normal file
278
src/app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"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<User[]>([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState<User[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userToDelete, setUserToDelete] = useState<string | null>(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 <div className={styles.loading}>Loading...</div>;
|
||||
}
|
||||
|
||||
if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminContainer}>
|
||||
<header className={styles.adminHeader}>
|
||||
<h1 className={styles.adminTitle}>Admin Dashboard</h1>
|
||||
<div className={styles.actionButtons}>
|
||||
<Link href="/dashboard">
|
||||
<button className={styles.dashboardButton}>Back to Dashboard</button>
|
||||
</Link>
|
||||
<button
|
||||
className={styles.statsButton}
|
||||
onClick={handleRecreateStatistics}
|
||||
disabled={isRecreatingStats}
|
||||
>
|
||||
{isRecreatingStats ? 'Recreating...' : 'Recreate Statistics'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.usersSection}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2>Manage Users</h2>
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
className={styles.clearSearchButton}
|
||||
onClick={() => setSearchTerm('')}
|
||||
title="Clear search"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className={styles.noResults}>
|
||||
{searchTerm ? 'No users match your search' : 'No users available'}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.usersTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account ID</th>
|
||||
<th>Created</th>
|
||||
<th>Role</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.account_id}>
|
||||
<td>{user.account_id}</td>
|
||||
<td>{user.created_at.toLocaleString()}</td>
|
||||
<td>{user.is_admin ? 'Admin' : 'User'}</td>
|
||||
<td className={styles.actions}>
|
||||
<Link href={`/admin/user/${user.account_id}`}>
|
||||
<button className={styles.viewButton}>
|
||||
View Details
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
{user.account_id !== session?.user?.accountId && (
|
||||
<button
|
||||
className={user.is_admin ? styles.removeAdminButton : styles.makeAdminButton}
|
||||
onClick={() => handleToggleAdminStatus(user.account_id)}
|
||||
>
|
||||
{user.is_admin ? 'Remove Admin' : 'Make Admin'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!user.is_admin && (
|
||||
<button
|
||||
className={styles.deleteButton}
|
||||
onClick={() => confirmDeleteUser(user.account_id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete User"
|
||||
message="Are you sure you want to delete this user? This will permanently remove their account and all associated data including links and analytics. This action cannot be undone."
|
||||
onConfirm={handleDeleteUser}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/app/admin/user/[accountId]/UserDetail.module.css
Normal file
174
src/app/admin/user/[accountId]/UserDetail.module.css
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
.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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
574
src/app/admin/user/[accountId]/links/[shortId]/page.tsx
Normal file
574
src/app/admin/user/[accountId]/links/[shortId]/page.tsx
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
"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<LinkType | null>(null);
|
||||
const [targetUrl, setTargetUrl] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [allAnalytics, setAllAnalytics] = useState<Analytics[]>([]);
|
||||
const [analytics, setAnalytics] = useState<Analytics[]>([]);
|
||||
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<string>('');
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||
const isRedirecting = useRef(false);
|
||||
|
||||
// Stats data
|
||||
const [browserStats, setBrowserStats] = useState<StatItem[]>([]);
|
||||
const [osStats, setOsStats] = useState<StatItem[]>([]);
|
||||
const [countryStats, setCountryStats] = useState<StatItem[]>([]);
|
||||
const [ipVersionStats, setIpVersionStats] = useState<StatItem[]>([]);
|
||||
|
||||
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<string, number>, item) => {
|
||||
const browser = item.browser || 'Unknown';
|
||||
acc[browser] = (acc[browser] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// OS stats
|
||||
const oses = allAnalytics.reduce((acc: Record<string, number>, item) => {
|
||||
const os = item.platform || 'Unknown';
|
||||
acc[os] = (acc[os] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Country stats
|
||||
const countries = allAnalytics.reduce((acc: Record<string, number>, item) => {
|
||||
const country = item.country || 'Unknown';
|
||||
acc[country] = (acc[country] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// IP version stats
|
||||
const ipVersions = allAnalytics.reduce((acc: Record<string, number>, 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 (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.loader}></div>
|
||||
<p>Loading link details...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!link) {
|
||||
return (
|
||||
<div className={styles.error}>Link not found or you don't have permission to view it.</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.title}>Admin Link Management</h1>
|
||||
<div className={styles.breadcrumbs}>
|
||||
<Link href="/admin" className={styles.breadcrumbLink}>Admin</Link> >
|
||||
<Link href={`/admin/user/${accountId}`} className={styles.breadcrumbLink}>User</Link> >
|
||||
<span className={styles.breadcrumbCurrent}>Link {shortId}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/admin/user/${accountId}`} className={styles.backLink}>
|
||||
Back to User
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.userInfo}>
|
||||
<span className={styles.label}>Managing link for User ID:</span>
|
||||
<span className={styles.value}>{accountId}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.linkInfo}>
|
||||
<div className={styles.linkCard}>
|
||||
<h2>Link Information</h2>
|
||||
<div className={styles.linkDetails}>
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Short ID:</span>
|
||||
<span className={styles.value}>{shortId}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.targetUrlHeader}>
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Short URL:</span>
|
||||
<a
|
||||
href={`${window.location.origin}/l/${shortId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.shortUrl}
|
||||
>
|
||||
{`${window.location.origin}/l/${shortId}`}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
className={styles.defaultButton}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/l/${shortId}`);
|
||||
showToast('URL copied to clipboard', 'success');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Created:</span>
|
||||
<span className={styles.value}>
|
||||
{link.created_at instanceof Date
|
||||
? link.created_at.toLocaleString()
|
||||
: new Date(link.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Last Modified:</span>
|
||||
<span className={styles.value}>
|
||||
{link.last_modified instanceof Date
|
||||
? link.last_modified.toLocaleString()
|
||||
: new Date(link.last_modified).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.targetUrlSection}>
|
||||
<div className={styles.targetUrlHeader}>
|
||||
<span className={styles.label}>Target URL:</span>
|
||||
{!isEditing && (
|
||||
<button
|
||||
className={styles.editButton}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleEditLink();
|
||||
}}
|
||||
className={styles.editForm}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
value={targetUrl}
|
||||
onChange={(e) => setTargetUrl(e.target.value)}
|
||||
className={styles.urlInput}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
<div className={styles.editActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelButton}
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setTargetUrl(link?.target_url || '');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.saveButton}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<a href={link.target_url} target="_blank" rel="noopener noreferrer" className={styles.targetUrl}>
|
||||
{link.target_url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.analyticsSection}>
|
||||
<div className={styles.analyticsHeader}>
|
||||
<h2>Analytics</h2>
|
||||
<span className={styles.totalClicks}>
|
||||
Total Clicks: {totalAnalytics}
|
||||
</span>
|
||||
{totalAnalytics > 0 && (
|
||||
<button
|
||||
className={styles.deleteAllButton}
|
||||
onClick={() => setShowDeleteAllModal(true)}
|
||||
>
|
||||
Delete All Analytics
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalAnalytics > 0 ? (
|
||||
<>
|
||||
<div className={styles.graphs}>
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Browsers</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={browserStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Operating Systems</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={osStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Countries</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={countryStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>IP Versions</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={ipVersionStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnalyticsTable
|
||||
analytics={analytics}
|
||||
allAnalytics={allAnalytics}
|
||||
totalItems={totalAnalytics}
|
||||
currentPage={page}
|
||||
itemsPerPage={limit}
|
||||
onPageChange={handlePageChange}
|
||||
onDeleteClick={(id) => {
|
||||
setAnalyticsToDelete(id);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.noAnalytics}>
|
||||
<p>No clicks recorded yet for this link.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Delete Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
title="Delete Analytics Entry"
|
||||
message="Are you sure you want to delete this analytics entry? This action cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={handleDeleteAnalytics}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setAnalyticsToDelete('');
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Confirm Delete All Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteAllModal}
|
||||
title="Delete All Analytics"
|
||||
message="Are you sure you want to delete all analytics for this link? This action cannot be undone."
|
||||
confirmLabel="Delete All"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={handleDeleteAllAnalytics}
|
||||
onCancel={() => setShowDeleteAllModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
src/app/admin/user/[accountId]/page.tsx
Normal file
241
src/app/admin/user/[accountId]/page.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"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<User | null>(null);
|
||||
const [links, setLinks] = useState([]);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revoking, setRevoking] = useState<string | null>(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 <div className={styles.loading}>Loading user details...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <div className={styles.error}>User not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>User Details</h1>
|
||||
<div className={styles.actionButtons}>
|
||||
<Link href="/admin">
|
||||
<button className={styles.backButton}>Back to Admin</button>
|
||||
</Link>
|
||||
{!user.is_admin && (
|
||||
<button
|
||||
className={styles.deleteButton}
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
>
|
||||
Delete User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.userInfo}>
|
||||
<h2>Account Information</h2>
|
||||
<div className={styles.infoCard}>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Account ID:</span>
|
||||
<span className={styles.infoValue}>{user.account_id}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Created:</span>
|
||||
<span className={styles.infoValue}>{formatDate(user.created_at)}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Role:</span>
|
||||
<span className={styles.infoValue}>{user.is_admin ? 'Admin' : 'User'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.linksSection}>
|
||||
<h2>User Links</h2>
|
||||
{links.length === 0 ? (
|
||||
<div className={styles.noItems}>This user has not created any links yet.</div>
|
||||
) : (
|
||||
<AdminLinkTable links={links} accountId={user.account_id} onLinkDeleted={fetchUserData} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.sessionsSection}>
|
||||
<h2>Active Sessions</h2>
|
||||
{sessions.length === 0 ? (
|
||||
<div className={styles.noItems}>This user has no active sessions.</div>
|
||||
) : (
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.sessionsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device & Browser</th>
|
||||
<th>IP Address</th>
|
||||
<th>Last Active</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.userAgent.split(' ').slice(0, 3).join(' ')}</td>
|
||||
<td>{s.ipAddress}</td>
|
||||
<td>{formatDate(s.lastActive)}</td>
|
||||
<td>{formatDate(s.createdAt)}</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => handleRevokeSession(s.id)}
|
||||
className={styles.revokeButton}
|
||||
disabled={revoking === s.id}
|
||||
>
|
||||
{revoking === s.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete User"
|
||||
message={`Are you sure you want to delete user ${accountId}? This will permanently remove their account and all associated data including links and analytics. This action cannot be undone.`}
|
||||
onConfirm={handleDeleteUser}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/app/api/admin/statistics/rebuild/route.ts
Normal file
31
src/app/api/admin/statistics/rebuild/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
67
src/app/api/admin/users/[accountId]/admin/route.ts
Normal file
67
src/app/api/admin/users/[accountId]/admin/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
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: { accountId: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const { accountId } = 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
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: { 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: params.accountId, shortId: params.shortId });
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve analytics",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { 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: params.accountId, shortId: params.shortId });
|
||||
return NextResponse.json({
|
||||
message: "Failed to delete analytics",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
137
src/app/api/admin/users/[accountId]/links/[shortId]/route.ts
Normal file
137
src/app/api/admin/users/[accountId]/links/[shortId]/route.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
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: { 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: params.accountId, shortId: params.shortId });
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve link details",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { 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: params.accountId, shortId: params.shortId });
|
||||
return NextResponse.json({
|
||||
message: "Failed to update link",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { 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: params.accountId, shortId: params.shortId });
|
||||
return NextResponse.json({
|
||||
message: "Failed to delete link",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
45
src/app/api/admin/users/[accountId]/links/route.ts
Normal file
45
src/app/api/admin/users/[accountId]/links/route.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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: { 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 });
|
||||
}
|
||||
}
|
||||
53
src/app/api/admin/users/[accountId]/route.ts
Normal file
53
src/app/api/admin/users/[accountId]/route.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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: { 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 });
|
||||
}
|
||||
}
|
||||
39
src/app/api/admin/users/[accountId]/sessions/revoke/route.ts
Normal file
39
src/app/api/admin/users/[accountId]/sessions/revoke/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
47
src/app/api/admin/users/[accountId]/sessions/route.ts
Normal file
47
src/app/api/admin/users/[accountId]/sessions/route.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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: { 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 });
|
||||
}
|
||||
}
|
||||
91
src/app/api/admin/users/route.ts
Normal file
91
src/app/api/admin/users/route.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
185
src/app/api/analytics/route.ts
Normal file
185
src/app/api/analytics/route.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
102
src/app/api/auth/[...nextauth]/route.ts
Normal file
102
src/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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 };
|
||||
43
src/app/api/auth/check-session/route.ts
Normal file
43
src/app/api/auth/check-session/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
61
src/app/api/auth/register/route.ts
Normal file
61
src/app/api/auth/register/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
47
src/app/api/auth/remove/route.ts
Normal file
47
src/app/api/auth/remove/route.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
46
src/app/api/auth/sessions/revoke/route.ts
Normal file
46
src/app/api/auth/sessions/revoke/route.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/auth/sessions/route.ts
Normal file
38
src/app/api/auth/sessions/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
252
src/app/api/link/route.ts
Normal file
252
src/app/api/link/route.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
44
src/app/api/links/route.ts
Normal file
44
src/app/api/links/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
33
src/app/api/statistics/route.ts
Normal file
33
src/app/api/statistics/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
163
src/app/dashboard/Dashboard.module.css
Normal file
163
src/app/dashboard/Dashboard.module.css
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
280
src/app/dashboard/link/[shortId]/LinkDetail.module.css
Normal file
280
src/app/dashboard/link/[shortId]/LinkDetail.module.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
545
src/app/dashboard/link/[shortId]/page.tsx
Normal file
545
src/app/dashboard/link/[shortId]/page.tsx
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
"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, { StatItem } 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 { Link as LinkType } from '@/types/link';
|
||||
|
||||
export default function LinkDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const shortId = params.shortId as string;
|
||||
|
||||
const [link, setLink] = useState<LinkType | null>(null);
|
||||
const [targetUrl, setTargetUrl] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [allAnalytics, setAllAnalytics] = useState<Analytics[]>([]);
|
||||
const [analytics, setAnalytics] = useState<Analytics[]>([]);
|
||||
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<string>('');
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||
const isRedirecting = useRef(false);
|
||||
|
||||
// Stats data
|
||||
const [browserStats, setBrowserStats] = useState<StatItem[]>([]);
|
||||
const [osStats, setOsStats] = useState<StatItem[]>([]);
|
||||
const [countryStats, setCountryStats] = useState<StatItem[]>([]);
|
||||
const [ipVersionStats, setIpVersionStats] = useState<StatItem[]>([]);
|
||||
|
||||
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<string, number>, item) => {
|
||||
const browser = item.browser || 'Unknown';
|
||||
acc[browser] = (acc[browser] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// OS stats
|
||||
const oses = allAnalytics.reduce((acc: Record<string, number>, item) => {
|
||||
const os = item.platform || 'Unknown';
|
||||
acc[os] = (acc[os] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Country stats
|
||||
const countries = allAnalytics.reduce((acc: Record<string, number>, item) => {
|
||||
const country = item.country || 'Unknown';
|
||||
acc[country] = (acc[country] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// IP version stats
|
||||
const ipVersions = allAnalytics.reduce((acc: Record<string, number>, 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 (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.loader}></div>
|
||||
<p>Loading link details...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.title}>Link Details</h1>
|
||||
<Link href="/dashboard" className={styles.backLink}>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.linkInfo}>
|
||||
<div className={styles.linkCard}>
|
||||
<h2>Link Information</h2>
|
||||
<div className={styles.linkDetails}>
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Short ID:</span>
|
||||
<span className={styles.value}>{shortId}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.targetUrlHeader}>
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Short URL:</span>
|
||||
<a
|
||||
href={`${window.location.origin}/l/${shortId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.shortUrl}
|
||||
>
|
||||
{`${window.location.origin}/l/${shortId}`}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
className={styles.defaultButton}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/l/${shortId}`);
|
||||
showToast('URL copied to clipboard', 'success');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Created:</span>
|
||||
<span className={styles.value}>
|
||||
{link ? new Date(link.created_at).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Last Modified:</span>
|
||||
<span className={styles.value}>
|
||||
{link ? new Date(link.last_modified).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.targetUrlSection}>
|
||||
<div className={styles.targetUrlHeader}>
|
||||
<span className={styles.label}>Target URL:</span>
|
||||
{!isEditing && (
|
||||
<button
|
||||
className={styles.defaultButton}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleEditLink();
|
||||
}}
|
||||
className={styles.editForm}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
value={targetUrl}
|
||||
onChange={(e) => setTargetUrl(e.target.value)}
|
||||
className={styles.urlInput}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
<div className={styles.editActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelButton}
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setTargetUrl(link?.target_url || '');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.saveButton}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<a
|
||||
href={link?.target_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.targetUrl}
|
||||
>
|
||||
{link?.target_url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.analyticsSection}>
|
||||
<div className={styles.analyticsHeader}>
|
||||
<h2>Analytics</h2>
|
||||
<span className={styles.totalClicks}>
|
||||
Total Clicks: {totalAnalytics}
|
||||
</span>
|
||||
{totalAnalytics > 0 && (
|
||||
<button
|
||||
className={styles.deleteAllButton}
|
||||
onClick={() => setShowDeleteAllModal(true)}
|
||||
>
|
||||
Delete All Analytics
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalAnalytics > 0 ? (
|
||||
<>
|
||||
<div className={styles.graphs}>
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Browsers</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={browserStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Operating Systems</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={osStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Countries</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={countryStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>IP Versions</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={ipVersionStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnalyticsTable
|
||||
analytics={analytics}
|
||||
allAnalytics={allAnalytics}
|
||||
totalItems={totalAnalytics}
|
||||
currentPage={page}
|
||||
itemsPerPage={limit}
|
||||
onPageChange={handlePageChange}
|
||||
onDeleteClick={(id) => {
|
||||
setAnalyticsToDelete(id);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.noAnalytics}>
|
||||
<p>No clicks recorded yet for this link.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Delete Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
title="Delete Analytics Entry"
|
||||
message="Are you sure you want to delete this analytics entry? This action cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={handleDeleteAnalytics}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setAnalyticsToDelete('');
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Confirm Delete All Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteAllModal}
|
||||
title="Delete All Analytics"
|
||||
message="Are you sure you want to delete all analytics for this link? This action cannot be undone."
|
||||
confirmLabel="Delete All"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={handleDeleteAllAnalytics}
|
||||
onCancel={() => setShowDeleteAllModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
src/app/dashboard/page.tsx
Normal file
164
src/app/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"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 <div className={styles.loading}>Loading...</div>;
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardContainer}>
|
||||
<header className={styles.dashboardHeader}>
|
||||
<h1 className={styles.dashboardTitle}>Dashboard</h1>
|
||||
<div className={styles.actionButtons}>
|
||||
<Link href="/dashboard/security">
|
||||
<button className={styles.securityButton}>Security Settings</button>
|
||||
</Link>
|
||||
|
||||
{session?.user?.isAdmin && (
|
||||
<Link href="/admin">
|
||||
<button className={styles.adminButton}>Admin Dashboard</button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.urlShortener}>
|
||||
<h2>Create New Short Link</h2>
|
||||
<CreateLinkForm onLinkCreated={fetchLinks} />
|
||||
</section>
|
||||
|
||||
<section className={styles.linksSection}>
|
||||
<h2>Your Shortened Links</h2>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>Loading your links...</div>
|
||||
) : links.length === 0 ? (
|
||||
<div className={styles.noLinks}>
|
||||
You haven't created any links yet. Create your first short link above!
|
||||
</div>
|
||||
) : (
|
||||
<LinkTable links={links} onLinkDeleted={fetchLinks} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className={styles.createForm}>
|
||||
<div className={styles.inputGroup}>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="Enter URL to shorten"
|
||||
className={styles.urlInput}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.createButton}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Short Link'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
120
src/app/dashboard/security/Security.module.css
Normal file
120
src/app/dashboard/security/Security.module.css
Normal file
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
96
src/app/dashboard/security/page.tsx
Normal file
96
src/app/dashboard/security/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={styles.container}>
|
||||
<header className={styles.header}>
|
||||
<h1>Security Settings</h1>
|
||||
<Link href="/dashboard" className={styles.backLink}>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className={styles.content}>
|
||||
<section className={styles.sessionSection}>
|
||||
<SessionManager />
|
||||
</section>
|
||||
|
||||
<section className={styles.dangerSection}>
|
||||
<h2>Danger Zone</h2>
|
||||
<div className={styles.dangerCard}>
|
||||
<div className={styles.dangerInfo}>
|
||||
<h3>Delete Account</h3>
|
||||
<p>
|
||||
This will permanently delete your account and all associated data.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className={styles.deleteAccountBtn}
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Account"
|
||||
message="Are you sure you want to delete your account? This will permanently remove your account and all your data, including all shortened links. This action cannot be undone."
|
||||
confirmLabel={isDeleting ? "Deleting..." : "Delete Account"}
|
||||
onConfirm={handleAccountDeletion}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
67
src/app/globals.css
Normal file
67
src/app/globals.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
36
src/app/l/[shortId]/route.ts
Normal file
36
src/app/l/[shortId]/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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: { 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));
|
||||
}
|
||||
}
|
||||
52
src/app/layout.tsx
Normal file
52
src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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 (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Providers>
|
||||
<ToastProvider>
|
||||
<Header />
|
||||
<div className="page-content">
|
||||
{children}
|
||||
</div>
|
||||
<Footer />
|
||||
<Toast />
|
||||
<SessionMonitor />
|
||||
<ResponsiveLayout />
|
||||
</ToastProvider>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
57
src/app/not-found/NotFound.module.css
Normal file
57
src/app/not-found/NotFound.module.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
.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);
|
||||
}
|
||||
18
src/app/not-found/page.tsx
Normal file
18
src/app/not-found/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import Link from 'next/link';
|
||||
import styles from './NotFound.module.css';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className={styles["default-container"]}>
|
||||
<section className={styles["hero-section"]}>
|
||||
<h1 className={styles["hero-title"]}>Link Not Found</h1>
|
||||
<p className={styles["hero-description"]}>
|
||||
The shortened link you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
<Link href="/" className={styles["hero-cta"]}>
|
||||
Go Home
|
||||
</Link>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
212
src/app/page.tsx
Normal file
212
src/app/page.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSession, signIn } from 'next-auth/react';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import Graph from '@/components/ui/Graph';
|
||||
import { StatItem } from '@/types/statistics';
|
||||
import styles from './Home.module.css';
|
||||
|
||||
// Fallback sample data in case API fails (hopefully not)
|
||||
const sampleData = {
|
||||
clicks: [
|
||||
{ id: 'Mon', count: 25 },
|
||||
{ id: 'Tue', count: 30 },
|
||||
{ id: 'Wed', count: 45 },
|
||||
{ id: 'Thu', count: 35 },
|
||||
{ id: 'Fri', count: 50 },
|
||||
{ id: 'Sat', count: 20 },
|
||||
{ id: 'Sun', count: 15 }
|
||||
],
|
||||
geoData: [
|
||||
{ id: 'United States', count: 120 },
|
||||
{ id: 'Germany', count: 80 },
|
||||
{ id: 'United Kingdom', count: 65 },
|
||||
{ id: 'Canada', count: 45 },
|
||||
{ id: 'France', count: 40 }
|
||||
],
|
||||
deviceData: [
|
||||
{ id: 'Desktop', count: 210 },
|
||||
{ id: 'Mobile', count: 180 },
|
||||
{ id: 'Tablet', count: 50 }
|
||||
]
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { status } = useSession();
|
||||
const { showToast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
|
||||
// State for real statistics data
|
||||
const [ipVersionStats, setIpVersionStats] = useState<StatItem[]>([]);
|
||||
const [osStats, setOsStats] = useState<StatItem[]>([]);
|
||||
const [countryStats, setCountryStats] = useState<StatItem[]>([]);
|
||||
const [ispStats, setIspStats] = useState<StatItem[]>([]);
|
||||
const [totalClicks, setTotalClicks] = useState(0);
|
||||
const [totalLinks, setTotalLinks] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStats() {
|
||||
try {
|
||||
setStatsLoading(true);
|
||||
const response = await fetch('/api/statistics');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch statistics');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.stats) {
|
||||
const { stats } = data;
|
||||
setTotalLinks(stats.total_links || 0);
|
||||
setTotalClicks(stats.total_clicks || 0);
|
||||
|
||||
if (stats.chart_data) {
|
||||
setIpVersionStats(stats.chart_data.ip_versions || []);
|
||||
setOsStats(stats.chart_data.os_stats || []);
|
||||
setCountryStats(stats.chart_data.country_stats || []);
|
||||
setIspStats(stats.chart_data.isp_stats || []);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching statistics:', error);
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const handleGetStarted = async () => {
|
||||
if (status === 'authenticated') {
|
||||
router.push('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.account_id) {
|
||||
const signInResult = await signIn('credentials', {
|
||||
accountId: data.account_id,
|
||||
redirect: false
|
||||
});
|
||||
|
||||
if (signInResult?.error) {
|
||||
throw new Error(signInResult.error);
|
||||
}
|
||||
|
||||
showToast('Account created successfully!', 'success');
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to create account');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
showToast('Failed to create an account. Please try again.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles["default-container"]}>
|
||||
{/* Hero Section */}
|
||||
<section className={styles["hero-section"]}>
|
||||
<h1 className={styles["hero-title"]}>µLinkShortener</h1>
|
||||
<p className={styles["hero-description"]}>
|
||||
An analytics-driven URL shortening service to track and manage your links.
|
||||
</p>
|
||||
<button
|
||||
className={styles["hero-cta"]}
|
||||
onClick={handleGetStarted}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Get Started'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<section className={styles["stats-summary"]}>
|
||||
<div className={styles["stats-container"]}>
|
||||
<div className={styles["stats-card"]}>
|
||||
<h3>Total Links</h3>
|
||||
<p className={styles["stats-number"]}>{statsLoading ? '...' : totalLinks.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className={styles["stats-card"]}>
|
||||
<h3>Total Clicks</h3>
|
||||
<p className={styles["stats-number"]}>{statsLoading ? '...' : totalClicks.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Graphs Section */}
|
||||
<section className={styles["graphs-section"]}>
|
||||
<h2 className={styles["graphs-title"]}>Analytics Dashboard</h2>
|
||||
<div className={styles["graphs-container"]}>
|
||||
<div className={styles["graph-card"]}>
|
||||
<h3 className={styles["graph-title"]}>IP Versions</h3>
|
||||
<div className={styles["graph-content"]}>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={ipVersionStats.length > 0 ? ipVersionStats : sampleData.deviceData}
|
||||
loading={statsLoading}
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["graph-card"]}>
|
||||
<h3 className={styles["graph-title"]}>Operating Systems</h3>
|
||||
<div className={styles["graph-content"]}>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={osStats.length > 0 ? osStats : sampleData.deviceData}
|
||||
loading={statsLoading}
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["graph-card"]}>
|
||||
<h3 className={styles["graph-title"]}>Countries</h3>
|
||||
<div className={styles["graph-content"]}>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={countryStats.length > 0 ? countryStats : sampleData.geoData}
|
||||
loading={statsLoading}
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["graph-card"]}>
|
||||
<h3 className={styles["graph-title"]}>Internet Service Providers</h3>
|
||||
<div className={styles["graph-content"]}>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={ispStats.length > 0 ? ispStats : sampleData.geoData}
|
||||
loading={statsLoading}
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
117
src/app/privacy/Privacy.module.css
Normal file
117
src/app/privacy/Privacy.module.css
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
.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);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 2rem 0 4rem;
|
||||
}
|
||||
|
||||
.policy-text-container {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.policy-section-title {
|
||||
font-size: 1.75rem;
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.policy-text {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.policy-text p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.policy-text ul {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.policy-text li {
|
||||
margin-bottom: 0.5rem;
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/* Make the first section title not have a large top margin */
|
||||
.policy-text-container .policy-section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.policy-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
77
src/app/privacy/page.tsx
Normal file
77
src/app/privacy/page.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import Link from 'next/link';
|
||||
import styles from './Privacy.module.css';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<main className={styles["default-container"]}>
|
||||
{/* Title Section */}
|
||||
<section className={styles["hero-section"]}>
|
||||
<h1 className={styles["hero-title"]}>Privacy Policy</h1>
|
||||
<p className={styles["hero-description"]}>
|
||||
We are committed to respecting user privacy while maintaining the integrity and security of our service. <br></br>This policy outlines what data we collect, why we collect it, and how it is used.
|
||||
</p>
|
||||
<Link href="/" className={styles["hero-cta"]}>
|
||||
Back to Home
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Privacy Content */}
|
||||
<section className={styles.content}>
|
||||
<div className={styles['policy-text-container']}>
|
||||
<h2 className={styles['policy-section-title']}>Information We Collect</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>When you use our URL shortening service, we may collect:</p>
|
||||
<ul>
|
||||
<li>IP address</li>
|
||||
<li>Browser and device information</li>
|
||||
<li>Operating system</li>
|
||||
<li>Referring websites</li>
|
||||
<li>ISP information</li>
|
||||
<li>Geographic location based on IP address</li>
|
||||
</ul>
|
||||
<p>We also use cookies to store your account session and preferences for your convenience.</p>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>How We Use Your Information</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>We use the collected information to:</p>
|
||||
<ul>
|
||||
<li>Provide and maintain our service</li>
|
||||
<li>Generate anonymized statistics</li>
|
||||
<li>Improve user experience</li>
|
||||
<li>Detect and prevent abusive usage</li>
|
||||
<li>Provide analytics to link creators</li>
|
||||
</ul>
|
||||
<p>We do <strong>not</strong> sell or share your personal data with third parties, except where required by law.</p>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Third-Party Services</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>We use Cloudflare as a content delivery network (CDN) and security provider. Cloudflare may process technical data such as IP addresses, request headers, and browser metadata to deliver and protect the service. This data is handled in accordance with <Link href="https://www.cloudflare.com/privacypolicy/" className={styles.link}>Cloudflare's Privacy Policy</Link>.</p>
|
||||
<p>We do not share user data with any other third-party services.</p>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Data Retention</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<ul>
|
||||
<li><strong>Analytics and usage data</strong> are retained until explicitly deleted by the link creator.</li>
|
||||
<li><strong>User accounts and associated data</strong> are retained until a deletion request is received.</li>
|
||||
<li>Shortened URLs remain active until deleted by their creator or by us in accordance with our Terms of Service.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Your Rights</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>You may request deletion of your account and associated data at any time by contacting us. Deletion is permanent and cannot be reversed.</p>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Contact Us</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>If you have any questions about this Privacy Policy, please contact us at:</p>
|
||||
<Link href="mailto:privacy.uLink@kizuren.dev" className={styles.link}>privacy.uLink@kizuren.dev</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
116
src/app/tos/ToS.module.css
Normal file
116
src/app/tos/ToS.module.css
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
.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);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 2rem 0 4rem;
|
||||
}
|
||||
|
||||
.policy-text-container {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.policy-section-title {
|
||||
font-size: 1.75rem;
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.policy-text {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.policy-text p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.policy-text ul {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.policy-text li {
|
||||
margin-bottom: 0.5rem;
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
.policy-text-container .policy-section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.policy-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
77
src/app/tos/page.tsx
Normal file
77
src/app/tos/page.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import Link from 'next/link';
|
||||
import styles from './ToS.module.css';
|
||||
|
||||
export default function TermsOfServicePage() {
|
||||
return (
|
||||
<main className={styles["default-container"]}>
|
||||
{/* Title Section */}
|
||||
<section className={styles["hero-section"]}>
|
||||
<h1 className={styles["hero-title"]}>Terms of Service</h1>
|
||||
<p className={styles["hero-description"]}>
|
||||
By using our URL shortening service, you agree to comply with these Terms of Service. Please read them carefully before using the platform.
|
||||
</p>
|
||||
<Link href="/" className={styles["hero-cta"]}>
|
||||
Back to Home
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Terms Content */}
|
||||
<section className={styles.content}>
|
||||
<div className={styles['policy-text-container']}>
|
||||
<h2 className={styles['policy-section-title']}>Acceptance of Terms</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>By accessing or using our URL shortening service, you agree to be bound by these Terms of Service. If you do not agree to these terms, do not use the service.</p>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Description of Service</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>We provide a URL shortening service with analytics and tracking functionality. The service is provided “as is,” without guarantees or warranties of any kind.</p>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>User Responsibilities</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>By using this service, you agree that you will <strong>not</strong>:</p>
|
||||
<ul>
|
||||
<li>Use the service for any unlawful or unauthorized purpose</li>
|
||||
<li>Distribute malware, phishing links, or any malicious code</li>
|
||||
<li>Infringe on any third party's intellectual property or proprietary rights</li>
|
||||
<li>Harass, spam, or abuse individuals or systems</li>
|
||||
<li>Attempt to probe, scan, or compromise our infrastructure or interfere with service operation</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Content Restrictions</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>You may <strong>not</strong> use the service to create or distribute links that direct to content which:</p>
|
||||
<ul>
|
||||
<li>Contains malware, viruses, or other harmful code</li>
|
||||
<li>Facilitates or promotes illegal activity</li>
|
||||
<li>Contains hate speech, discriminatory, or violent material</li>
|
||||
<li>Infringes on intellectual property rights</li>
|
||||
<li>Includes adult or explicit content without compliant age verification</li>
|
||||
<li>Encourages self-harm, suicide, or criminal activity</li>
|
||||
</ul>
|
||||
<p>We reserve the right to remove or disable any links at any time without explanation.</p>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Service Modifications</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>We may modify, suspend, or discontinue any part of the service at any time, with or without notice. We are not liable for any loss, data deletion, or disruption caused by such changes.</p>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Termination</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>We may suspend or terminate your access to the service at any time, with or without notice, for any reason we deem appropriate. This includes, but is not limited to, violations of these Terms, behavior we consider abusive, disruptive, unlawful, or harmful to the service, to us, to other users, or to third parties. Termination is at our sole discretion and may be irreversible. We are under no obligation to preserve, return, or provide access to any data following termination.</p>
|
||||
<p>Attempts to bypass suspension or re-register after termination may result in permanent blocking.</p>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Contact Us</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>If you have any questions about these Terms of Service, please contact us at:</p>
|
||||
<Link href="mailto:terms.uLink@kizuren.dev" className={styles.link}>terms.uLink@kizuren.dev</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
53
src/components/Footer.module.css
Normal file
53
src/components/Footer.module.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
min-height: var(--footer-min-height);
|
||||
background-color: var(--bg-color);
|
||||
border-top: 1px solid var(--primary-color);
|
||||
padding: 1rem 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
color: var(--text-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
222
src/components/Header.module.css
Normal file
222
src/components/Header.module.css
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
min-height: var(--header-min-height);
|
||||
background-color: var(--bg-color);
|
||||
border-bottom: 1px solid var(--primary-color);
|
||||
padding: 1rem 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 90%;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1rem, 2vw + 0.5rem, 1.5rem);
|
||||
color: var(--primary-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logo a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth {
|
||||
display: flex;
|
||||
gap: clamp(4px, 1vw, 10px);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
/* Base button styles for all buttons */
|
||||
.loginBtn, .registerBtn, .loginSubmitBtn, .logoutBtn, .dashboardBtn {
|
||||
background-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: clamp(0.75rem, 1vw + 0.5rem, 0.9rem);
|
||||
padding: clamp(4px, 1vw, 8px) clamp(8px, 2vw, 16px);
|
||||
}
|
||||
|
||||
.loginBtn:hover, .registerBtn:hover, .loginSubmitBtn:hover, .dashboardBtn:hover {
|
||||
background-color: var(--accent-hover);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.logoutBtn {
|
||||
background-color: var(--error);
|
||||
}
|
||||
|
||||
.logoutBtn:hover {
|
||||
background-color: var(--error);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: clamp(0.75rem, 1vw + 0.5rem, 0.9rem);
|
||||
padding: clamp(4px, 1vw, 8px) clamp(8px, 2vw, 16px);
|
||||
}
|
||||
|
||||
.cancelBtn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.auth {
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.auth {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.loginBtn, .registerBtn {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.accountInput {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: clamp(6px, 1vw, 8px) clamp(8px, 1.5vw, 12px);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
transition: border-color 0.3s;
|
||||
font-size: clamp(0.75rem, 1vw + 0.5rem, 0.9rem);
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.cancelBtn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.animateIn {
|
||||
animation: slideIn 0.3s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.loginForm {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.auth {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
gap: clamp(5px, 1vw, 10px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.accountIdDisplay {
|
||||
font-size: clamp(0.7rem, 1vw + 0.4rem, 0.9rem);
|
||||
padding: clamp(3px, 0.5vw, 4px) clamp(6px, 1vw, 8px);
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.accountIdDisplay:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.accountIdDisplay:hover .copyIndicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copyMessage {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.copied .copyMessage {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.idLabel {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
11
src/components/Providers.tsx
Normal file
11
src/components/Providers.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
43
src/components/ResponsiveLayout.tsx
Normal file
43
src/components/ResponsiveLayout.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function ResponsiveLayout() {
|
||||
const adjustLayout = () => {
|
||||
const header = document.querySelector('header');
|
||||
const footer = document.querySelector('footer');
|
||||
const content = document.querySelector('.page-content');
|
||||
|
||||
if (header && footer && content) {
|
||||
const headerHeight = header.getBoundingClientRect().height;
|
||||
const footerHeight = footer.getBoundingClientRect().height;
|
||||
|
||||
(content as HTMLElement).style.paddingTop = `${headerHeight}px`;
|
||||
(content as HTMLElement).style.paddingBottom = `${footerHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
adjustLayout();
|
||||
window.addEventListener('resize', adjustLayout);
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setTimeout(adjustLayout, 100);
|
||||
});
|
||||
|
||||
const header = document.querySelector('header');
|
||||
const footer = document.querySelector('footer');
|
||||
|
||||
if (header && footer) {
|
||||
observer.observe(header, { subtree: true, childList: true, attributes: true });
|
||||
observer.observe(footer, { subtree: true, childList: true, attributes: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', adjustLayout);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
45
src/components/SessionMonitor.tsx
Normal file
45
src/components/SessionMonitor.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { signOut, useSession } from 'next-auth/react';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
export default function SessionMonitor() {
|
||||
const { data: session } = useSession();
|
||||
const { showToast } = useToast();
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
if (!session?.user) return;
|
||||
|
||||
const checkSession = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/check-session');
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('Session check failed:', data.message);
|
||||
|
||||
showToast('Your session has expired or been revoked from another device', 'error');
|
||||
await signOut({ redirect: true, callbackUrl: '/' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
intervalRef.current = setInterval(checkSession, 10000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [session, showToast]);
|
||||
|
||||
return null;
|
||||
}
|
||||
19
src/components/footer.tsx
Normal file
19
src/components/footer.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Link from 'next/link';
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.copyright}>
|
||||
© {new Date().getFullYear()} µLinkShortener
|
||||
</div>
|
||||
<div className={styles.links}>
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
<Link href="/tos">Terms of Service</Link>
|
||||
<Link href="https://github.com/Kizuren/uLinkShortener">GitHub</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
197
src/components/header.tsx
Normal file
197
src/components/header.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import styles from './Header.module.css';
|
||||
import LoadingIcon from '@/components/ui/LoadingIcon';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
const copyAccountIdToClipboard = (accountId: string) => {
|
||||
if (navigator.clipboard && accountId) {
|
||||
navigator.clipboard.writeText(accountId)
|
||||
.then(() => {
|
||||
const displayElement = document.querySelector(`.${styles.accountIdDisplay}`);
|
||||
if (displayElement) {
|
||||
displayElement.classList.add(styles.copied);
|
||||
setTimeout(() => {
|
||||
displayElement.classList.remove(styles.copied);
|
||||
}, 1500);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Header() {
|
||||
const [showLoginForm, setShowLoginForm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const isLoggedIn = status === "authenticated";
|
||||
const accountId = session?.user?.accountId as string || '';
|
||||
|
||||
useEffect(() => {
|
||||
if (showLoginForm && inputRef.current) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 10);
|
||||
}
|
||||
}, [showLoginForm]);
|
||||
|
||||
const handleLoginSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const enteredAccountId = inputRef.current?.value;
|
||||
|
||||
if (enteredAccountId) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
accountId: enteredAccountId,
|
||||
redirect: false
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
showToast('Account not found. Please check your Account ID.', 'error');
|
||||
} else {
|
||||
setShowLoginForm(false);
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
showToast('An error occurred during login. Please try again.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.account_id) {
|
||||
await signIn("credentials", {
|
||||
accountId: data.account_id,
|
||||
redirect: false
|
||||
});
|
||||
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to create account');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
showToast('An error occurred during registration. Please try again.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await signOut({ redirect: false });
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
showToast('An error occurred during logout. Please try again.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.logo}>
|
||||
<Link href="/">
|
||||
<h1>µLinkShortener</h1>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.auth}>
|
||||
{status === "loading" || isLoading ? (
|
||||
<LoadingIcon size={24} color="var(--accent)" />
|
||||
) : isLoggedIn ? (
|
||||
<div className={`${styles.userInfo} ${styles.animateIn}`}>
|
||||
<span
|
||||
className={styles.accountIdDisplay}
|
||||
onClick={() => copyAccountIdToClipboard(accountId)}
|
||||
title="Click to copy account ID"
|
||||
>
|
||||
<span className={styles.idLabel}>Account ID: </span>
|
||||
{accountId}
|
||||
<span className={styles.copyMessage}>Copied!</span>
|
||||
</span>
|
||||
<button
|
||||
className={styles.logoutBtn}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<Link href="/dashboard">
|
||||
<button className={styles.dashboardBtn}>Dashboard</button>
|
||||
</Link>
|
||||
</div>
|
||||
) : !showLoginForm ? (
|
||||
<>
|
||||
<button
|
||||
className={styles.loginBtn}
|
||||
onClick={() => setShowLoginForm(true)}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
className={styles.registerBtn}
|
||||
onClick={handleRegister}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleLoginSubmit}
|
||||
className={`${styles.loginForm} ${styles.animateIn}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Enter Account ID"
|
||||
pattern="[0-9]*"
|
||||
inputMode="numeric"
|
||||
className={styles.accountInput}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className={styles.loginSubmitBtn}>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
onClick={() => setShowLoginForm(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
84
src/components/ui/ConfirmModal.module.css
Normal file
84
src/components/ui/ConfirmModal.module.css
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
.modalOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
margin-bottom: 24px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cancelButton,
|
||||
.confirmButton {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.confirmButton {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cancelButton:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.confirmButton:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(30px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
90
src/components/ui/ConfirmModal.tsx
Normal file
90
src/components/ui/ConfirmModal.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import styles from './ConfirmModal.module.css';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Delete",
|
||||
cancelLabel = "Cancel",
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Focus trap for accessibility
|
||||
if (isOpen) {
|
||||
// Prevent scrolling of background content
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Auto focus the first button
|
||||
const focusableElements = modalRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (focusableElements && focusableElements.length > 0) {
|
||||
(focusableElements[0] as HTMLElement).focus();
|
||||
}
|
||||
|
||||
// Handle escape key press
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [isOpen, onCancel]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay} onClick={onCancel}>
|
||||
<div
|
||||
className={styles.modalContent}
|
||||
onClick={e => e.stopPropagation()}
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-labelledby="modal-title"
|
||||
aria-modal="true"
|
||||
>
|
||||
<h3 id="modal-title" className={styles.modalTitle}>{title}</h3>
|
||||
<div className={styles.modalBody}>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<button
|
||||
className={styles.cancelButton}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
className={styles.confirmButton}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/ui/Graph.module.css
Normal file
46
src/components/ui/Graph.module.css
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
.graphContainer {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--card-bg);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.noData {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.graphContainer {
|
||||
height: auto !important;
|
||||
min-height: 15.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.graphContainer {
|
||||
min-height: 12.5rem;
|
||||
}
|
||||
}
|
||||
296
src/components/ui/Graph.tsx
Normal file
296
src/components/ui/Graph.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { Bar, Line, Doughnut } from 'react-chartjs-2';
|
||||
import styles from './Graph.module.css';
|
||||
import LoadingIcon from './LoadingIcon';
|
||||
import type { StatItem } from '@/types/statistics';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
export type GraphType = 'bar' | 'line' | 'doughnut';
|
||||
|
||||
interface GraphProps {
|
||||
title?: string;
|
||||
type: GraphType;
|
||||
data: StatItem[];
|
||||
loading?: boolean;
|
||||
height?: number;
|
||||
maxItems?: number;
|
||||
colors?: string[];
|
||||
}
|
||||
|
||||
const defaultColors = [
|
||||
'#6366f1', // Indigo
|
||||
'#4f46e5', // Darker indigo
|
||||
'#8b5cf6', // Purple
|
||||
'#ec4899', // Pink
|
||||
'#ef4444', // Red
|
||||
'#f59e0b', // Amber
|
||||
'#10b981', // Emerald
|
||||
'#3b82f6', // Blue
|
||||
'#a855f7', // Purple
|
||||
'#14b8a6', // Teal
|
||||
];
|
||||
|
||||
export default function Graph({
|
||||
title,
|
||||
type,
|
||||
data,
|
||||
loading = false,
|
||||
height = 200,
|
||||
maxItems = 8,
|
||||
colors = defaultColors
|
||||
}: GraphProps) {
|
||||
const [chartLabels, setChartLabels] = useState<string[]>([]);
|
||||
const [chartValues, setChartValues] = useState<number[]>([]);
|
||||
const [chartColors, setChartColors] = useState<string[]>([]);
|
||||
const chartRef = useRef<ChartJS | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Process data and update state
|
||||
useEffect(() => {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
// Limit data to maxItems and group the rest as "Others"
|
||||
const processedData = [...data];
|
||||
let labels: string[] = [];
|
||||
let values: number[] = [];
|
||||
|
||||
if (processedData.length > maxItems) {
|
||||
const topItems = processedData.slice(0, maxItems - 1);
|
||||
const others = processedData.slice(maxItems - 1);
|
||||
|
||||
labels = topItems.map(item => item.id);
|
||||
values = topItems.map(item => item.count);
|
||||
|
||||
const othersSum = others.reduce((sum, item) => sum + item.count, 0);
|
||||
labels.push('Others');
|
||||
values.push(othersSum);
|
||||
} else {
|
||||
labels = processedData.map(item => item.id);
|
||||
values = processedData.map(item => item.count);
|
||||
}
|
||||
|
||||
setChartLabels(labels);
|
||||
setChartValues(values);
|
||||
setChartColors(colors.slice(0, labels.length));
|
||||
}, [data, maxItems, colors]);
|
||||
|
||||
// Handle resize and respond to container size changes
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.update();
|
||||
}
|
||||
});
|
||||
|
||||
const currentContainer = containerRef.current;
|
||||
|
||||
if (currentContainer) {
|
||||
resizeObserver.observe(currentContainer);
|
||||
}
|
||||
|
||||
// Also handle window resize
|
||||
const handleResize = () => {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.update();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
if (currentContainer) {
|
||||
resizeObserver.unobserve(currentContainer);
|
||||
}
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getOptions = () => {
|
||||
const baseOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: type === 'doughnut',
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: 'white',
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
boxWidth: 12,
|
||||
padding: 10
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'var(--card-bg)',
|
||||
titleColor: 'white',
|
||||
bodyColor: 'white',
|
||||
borderColor: 'var(--border-color)',
|
||||
borderWidth: 1,
|
||||
padding: 8,
|
||||
boxWidth: 10,
|
||||
boxHeight: 10
|
||||
}
|
||||
},
|
||||
color: 'white'
|
||||
};
|
||||
|
||||
// Only add scales for bar and line charts
|
||||
if (type !== 'doughnut') {
|
||||
return {
|
||||
...baseOptions,
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: 'white',
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 10
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)',
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: 'white',
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
precision: 0
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)',
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
};
|
||||
|
||||
// Create properly typed data objects for each chart type
|
||||
const getBarData = () => ({
|
||||
labels: chartLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: title || 'Data',
|
||||
data: chartValues,
|
||||
backgroundColor: chartColors,
|
||||
borderColor: chartColors,
|
||||
borderWidth: 1,
|
||||
borderRadius: 4
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const getLineData = () => ({
|
||||
labels: chartLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: title || 'Data',
|
||||
data: chartValues,
|
||||
backgroundColor: chartColors[0],
|
||||
borderColor: chartColors[0],
|
||||
borderWidth: 2,
|
||||
tension: 0.1,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const getDoughnutData = () => ({
|
||||
labels: chartLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: title || 'Data',
|
||||
data: chartValues,
|
||||
backgroundColor: chartColors,
|
||||
borderColor: chartColors,
|
||||
borderWidth: 1,
|
||||
hoverOffset: 5
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.graphContainer} style={{ height: `${height}px` }}>
|
||||
{loading ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<LoadingIcon size={40} />
|
||||
</div>
|
||||
) : data && data.length > 0 ? (
|
||||
<>
|
||||
{type === 'bar' && (
|
||||
<Bar
|
||||
data={getBarData()}
|
||||
options={getOptions()}
|
||||
ref={(ref) => {
|
||||
if (ref) chartRef.current = ref.chartInstance;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type === 'line' && (
|
||||
<Line
|
||||
data={getLineData()}
|
||||
options={getOptions()}
|
||||
ref={(ref) => {
|
||||
if (ref) chartRef.current = ref.chartInstance;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type === 'doughnut' && (
|
||||
<Doughnut
|
||||
data={getDoughnutData()}
|
||||
options={getOptions()}
|
||||
ref={(ref) => {
|
||||
if (ref) chartRef.current = ref.chartInstance;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.noData}>No data available</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/ui/LoadingIcon.tsx
Normal file
46
src/components/ui/LoadingIcon.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
interface LoadingIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
thickness?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LoadingIcon({
|
||||
size = 24,
|
||||
color = 'var(--accent)',
|
||||
thickness = 2,
|
||||
className = ''
|
||||
}: LoadingIconProps) {
|
||||
return (
|
||||
<div
|
||||
className={`loading-spinner ${className}`}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderWidth: `${thickness}px`,
|
||||
borderColor: `${color}20`,
|
||||
borderTopColor: color,
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/components/ui/Toast.module.css
Normal file
57
src/components/ui/Toast.module.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
.toastContainer {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
border-left: 4px solid #c62828;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background-color: var(--success);
|
||||
color: white;
|
||||
border-left: 4px solid #2e7d32;
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
border-left: 4px solid var(--accent-hover);
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
background-color: var(--warning);
|
||||
color: white;
|
||||
border-left: 4px solid #f57c00;
|
||||
}
|
||||
|
||||
.toastShow {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
animation: fadeOut 2s forwards;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
24
src/components/ui/Toast.tsx
Normal file
24
src/components/ui/Toast.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import styles from './Toast.module.css';
|
||||
|
||||
export default function Toast() {
|
||||
const { toasts, hideToast } = useToast();
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.toastContainer}>
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.type]} ${styles.toastShow}`}
|
||||
onClick={() => hideToast(toast.id)}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
src/components/ui/admin/AdminLinkTable.module.css
Normal file
219
src/components/ui/admin/AdminLinkTable.module.css
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
.tableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
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;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.clearSearchButton {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.clearSearchButton:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.noResults {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.linkTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.linkTable th,
|
||||
.linkTable td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.linkTable th {
|
||||
background-color: var(--table-header-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.linkTable tr:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.shortLinkCell {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.shortLink {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
.shortLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.targetUrl {
|
||||
width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.targetUrl a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.targetUrl a:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.copyButton,
|
||||
.deleteButton {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.deleteButton:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.deleteButton:disabled {
|
||||
background-color: #dc354580;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.hideOnMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shortLinkCell {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.targetUrl {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.targetUrl a {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 60%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.copyButton,
|
||||
.deleteButton {
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* For very small screens */
|
||||
@media (max-width: 480px) {
|
||||
.tableContainer {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.linkTable {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.linkTable th,
|
||||
.linkTable td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.shortLinkCell {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.targetUrl {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.targetUrl a {
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
203
src/components/ui/admin/AdminLinkTable.tsx
Normal file
203
src/components/ui/admin/AdminLinkTable.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import ConfirmModal from '@/components/ui/ConfirmModal';
|
||||
import styles from './AdminLinkTable.module.css';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
interface LinkData {
|
||||
short_id: string;
|
||||
target_url: string;
|
||||
created_at: string;
|
||||
last_modified: string;
|
||||
}
|
||||
|
||||
interface LinkTableProps {
|
||||
links: LinkData[];
|
||||
accountId: string
|
||||
onLinkDeleted: () => void;
|
||||
}
|
||||
|
||||
export default function AdminLinkTable({ links, accountId, onLinkDeleted }: LinkTableProps) {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filteredLinks, setFilteredLinks] = useState<LinkData[]>(links);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [linkToDelete, setLinkToDelete] = useState<string | null>(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredLinks(links);
|
||||
}, [links]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
setFilteredLinks(links);
|
||||
return;
|
||||
}
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
const filtered = links.filter(link =>
|
||||
link.short_id.toLowerCase().includes(term) ||
|
||||
link.target_url.toLowerCase().includes(term) ||
|
||||
new Date(link.created_at).toLocaleString().toLowerCase().includes(term) ||
|
||||
new Date(link.last_modified).toLocaleString().toLowerCase().includes(term)
|
||||
);
|
||||
|
||||
setFilteredLinks(filtered);
|
||||
}, [searchTerm, links]);
|
||||
|
||||
const copyToClipboard = (shortId: string) => {
|
||||
const fullUrl = `${window.location.origin}/l/${shortId}`;
|
||||
navigator.clipboard.writeText(fullUrl)
|
||||
.then(() => {
|
||||
showToast('Link copied to clipboard!', 'success');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy link:', err);
|
||||
showToast('Failed to copy link', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDelete = (shortId: string) => {
|
||||
setLinkToDelete(shortId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
setLinkToDelete(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
};
|
||||
|
||||
const handleDeleteLink = async () => {
|
||||
if (!linkToDelete) return;
|
||||
|
||||
try {
|
||||
setDeletingId(linkToDelete);
|
||||
const response = await fetch(`/api/admin/users/${accountId}/links/${linkToDelete}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ shortId: linkToDelete }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast('Link deleted successfully!', 'success');
|
||||
if (onLinkDeleted) onLinkDeleted();
|
||||
} else {
|
||||
showToast(data.message || 'Failed to delete link', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting link:', error);
|
||||
showToast('Failed to delete link', 'error');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setLinkToDelete(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const truncateUrl = (url: string, maxLength: number = 50) => {
|
||||
return url.length > maxLength ? `${url.substring(0, maxLength)}...` : url;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper}>
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search links..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
className={styles.clearSearchButton}
|
||||
onClick={() => setSearchTerm('')}
|
||||
title="Clear search"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredLinks.length === 0 && (
|
||||
<div className={styles.noResults}>
|
||||
{searchTerm ? 'No links match your search' : 'No links available'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredLinks.length > 0 && (
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.linkTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
<th>Target URL</th>
|
||||
<th className={styles.hideOnMobile}>Created</th>
|
||||
<th className={styles.hideOnMobile}>Last Modified</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredLinks.map((link) => (
|
||||
<tr key={link.short_id}>
|
||||
<td className={styles.shortLinkCell}>
|
||||
<Link
|
||||
href={`/admin/user/${accountId}/links/${link.short_id}`}
|
||||
className={styles.shortLink}
|
||||
>
|
||||
{link.short_id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className={styles.targetUrl} title={link.target_url}>
|
||||
<a href={link.target_url} target="_blank" rel="noopener noreferrer">
|
||||
{truncateUrl(link.target_url)}
|
||||
</a>
|
||||
</td>
|
||||
<td className={styles.hideOnMobile}>{formatDate(link.created_at)}</td>
|
||||
<td className={styles.hideOnMobile}>{formatDate(link.last_modified)}</td>
|
||||
<td className={styles.actions}>
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={() => copyToClipboard(link.short_id)}
|
||||
title="Copy full short URL to clipboard"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className={styles.deleteButton}
|
||||
onClick={() => confirmDelete(link.short_id)}
|
||||
disabled={deletingId === link.short_id}
|
||||
title="Delete this link"
|
||||
>
|
||||
{deletingId === link.short_id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Link"
|
||||
message="Are you sure you want to delete this link? This action cannot be undone."
|
||||
onConfirm={handleDeleteLink}
|
||||
onCancel={cancelDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
src/components/ui/dashboard/LinkTable.module.css
Normal file
219
src/components/ui/dashboard/LinkTable.module.css
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
.tableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
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;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.clearSearchButton {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.clearSearchButton:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.noResults {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.linkTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.linkTable th,
|
||||
.linkTable td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.linkTable th {
|
||||
background-color: var(--table-header-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.linkTable tr:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.shortLinkCell {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.shortLink {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
.shortLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.targetUrl {
|
||||
width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.targetUrl a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.targetUrl a:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.copyButton,
|
||||
.deleteButton {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.deleteButton:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.deleteButton:disabled {
|
||||
background-color: #dc354580;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.hideOnMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shortLinkCell {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.targetUrl {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.targetUrl a {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 60%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.copyButton,
|
||||
.deleteButton {
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* For very small screens */
|
||||
@media (max-width: 480px) {
|
||||
.tableContainer {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.linkTable {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.linkTable th,
|
||||
.linkTable td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.shortLinkCell {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.targetUrl {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.targetUrl a {
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
196
src/components/ui/dashboard/LinkTable.tsx
Normal file
196
src/components/ui/dashboard/LinkTable.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import ConfirmModal from '@/components/ui/ConfirmModal';
|
||||
import styles from './LinkTable.module.css';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import type { Link as LinkData } from '@/types/link';
|
||||
|
||||
interface LinkTableProps {
|
||||
links: LinkData[];
|
||||
onLinkDeleted: () => void;
|
||||
}
|
||||
|
||||
export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filteredLinks, setFilteredLinks] = useState<LinkData[]>(links);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [linkToDelete, setLinkToDelete] = useState<string | null>(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredLinks(links);
|
||||
}, [links]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
setFilteredLinks(links);
|
||||
return;
|
||||
}
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
const filtered = links.filter(link =>
|
||||
link.short_id.toLowerCase().includes(term) ||
|
||||
link.target_url.toLowerCase().includes(term) ||
|
||||
new Date(link.created_at).toLocaleString().toLowerCase().includes(term) ||
|
||||
new Date(link.last_modified).toLocaleString().toLowerCase().includes(term)
|
||||
);
|
||||
|
||||
setFilteredLinks(filtered);
|
||||
}, [searchTerm, links]);
|
||||
|
||||
const copyToClipboard = (shortId: string) => {
|
||||
const fullUrl = `${window.location.origin}/l/${shortId}`;
|
||||
navigator.clipboard.writeText(fullUrl)
|
||||
.then(() => {
|
||||
showToast('Link copied to clipboard!', 'success');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy link:', err);
|
||||
showToast('Failed to copy link', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDelete = (shortId: string) => {
|
||||
setLinkToDelete(shortId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
setLinkToDelete(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
};
|
||||
|
||||
const handleDeleteLink = async () => {
|
||||
if (!linkToDelete) return;
|
||||
|
||||
try {
|
||||
setDeletingId(linkToDelete);
|
||||
const response = await fetch('/api/link', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ shortId: linkToDelete }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast('Link deleted successfully!', 'success');
|
||||
if (onLinkDeleted) onLinkDeleted();
|
||||
} else {
|
||||
showToast(data.message || 'Failed to delete link', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting link:', error);
|
||||
showToast('Failed to delete link', 'error');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setLinkToDelete(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: Date) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const truncateUrl = (url: string, maxLength: number = 50) => {
|
||||
return url.length > maxLength ? `${url.substring(0, maxLength)}...` : url;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper}>
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search links..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
className={styles.clearSearchButton}
|
||||
onClick={() => setSearchTerm('')}
|
||||
title="Clear search"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredLinks.length === 0 && (
|
||||
<div className={styles.noResults}>
|
||||
{searchTerm ? 'No links match your search' : 'No links available'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredLinks.length > 0 && (
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.linkTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
<th>Target URL</th>
|
||||
<th className={styles.hideOnMobile}>Created</th>
|
||||
<th className={styles.hideOnMobile}>Last Modified</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredLinks.map((link) => (
|
||||
<tr key={link.short_id}>
|
||||
<td className={styles.shortLinkCell}>
|
||||
<Link
|
||||
href={`/dashboard/link/${link.short_id}`}
|
||||
className={styles.shortLink}
|
||||
>
|
||||
{link.short_id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className={styles.targetUrl} title={link.target_url}>
|
||||
<a href={link.target_url} target="_blank" rel="noopener noreferrer">
|
||||
{truncateUrl(link.target_url)}
|
||||
</a>
|
||||
</td>
|
||||
<td className={styles.hideOnMobile}>{formatDate(link.created_at)}</td>
|
||||
<td className={styles.hideOnMobile}>{formatDate(link.last_modified)}</td>
|
||||
<td className={styles.actions}>
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={() => copyToClipboard(link.short_id)}
|
||||
title="Copy full short URL to clipboard"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className={styles.deleteButton}
|
||||
onClick={() => confirmDelete(link.short_id)}
|
||||
disabled={deletingId === link.short_id}
|
||||
title="Delete this link"
|
||||
>
|
||||
{deletingId === link.short_id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Link"
|
||||
message="Are you sure you want to delete this link? This action cannot be undone."
|
||||
onConfirm={handleDeleteLink}
|
||||
onCancel={cancelDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/components/ui/dashboard/SessionManager.module.css
Normal file
157
src/components/ui/dashboard/SessionManager.module.css
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
.sessionManager {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sessionManager h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
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;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.clearSearchButton {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.clearSearchButton:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading, .error, .noSessions {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
border: 1px solid rgba(220, 53, 69, 0.2);
|
||||
background-color: rgba(220, 53, 69, 0.05);
|
||||
}
|
||||
|
||||
.noSessions {
|
||||
color: var(--text-secondary);
|
||||
border: 1px dashed var(--border-color);
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sessionsTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.sessionsTable th,
|
||||
.sessionsTable td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.sessionsTable th {
|
||||
background-color: var(--table-header-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.deviceCell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.currentSession {
|
||||
background-color: rgba(46, 204, 113, 0.05);
|
||||
}
|
||||
|
||||
.currentSessionText {
|
||||
color: #2ecc71;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.revokeButton {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.revokeButton:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
/* Responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.sessionsTable th:nth-child(3),
|
||||
.sessionsTable td:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sessionsTable th:nth-child(4),
|
||||
.sessionsTable td:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.sessionsTable {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sessionsTable th,
|
||||
.sessionsTable td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.revokeButton {
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
189
src/components/ui/dashboard/SessionManager.tsx
Normal file
189
src/components/ui/dashboard/SessionManager.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import styles from './SessionManager.module.css';
|
||||
import type { SessionInfo } from '@/types/session';
|
||||
|
||||
export default function SessionManager() {
|
||||
const { data: session } = useSession();
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [filteredSessions, setFilteredSessions] = useState<SessionInfo[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [revoking, setRevoking] = useState<string | null>(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
const isFetchingSessions = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetchingSessions.current) return;
|
||||
|
||||
async function fetchSessions() {
|
||||
if (!session?.user?.accountId) return;
|
||||
|
||||
try {
|
||||
isFetchingSessions.current = true;
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch('/api/auth/sessions');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch sessions');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSessions(data.sessions);
|
||||
setFilteredSessions(data.sessions);
|
||||
} else {
|
||||
setError(data.message || 'Failed to load sessions');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching sessions:', error);
|
||||
setError('Failed to load sessions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isFetchingSessions.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
fetchSessions();
|
||||
}, [session?.user?.accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
setFilteredSessions(sessions);
|
||||
return;
|
||||
}
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
const filtered = sessions.filter(s =>
|
||||
s.userAgent.toLowerCase().includes(term) ||
|
||||
s.ipAddress.toLowerCase().includes(term) ||
|
||||
new Date(s.lastActive).toLocaleString().toLowerCase().includes(term) ||
|
||||
new Date(s.createdAt).toLocaleString().toLowerCase().includes(term)
|
||||
);
|
||||
|
||||
setFilteredSessions(filtered);
|
||||
}, [searchTerm, sessions]);
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
try {
|
||||
const sessionToRevoke = sessions.find(s => s.id === sessionId);
|
||||
if (sessionToRevoke?.isCurrentSession) {
|
||||
showToast("You cannot revoke your current session", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setRevoking(sessionId);
|
||||
|
||||
const response = await fetch('/api/auth/sessions/revoke', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId })
|
||||
});
|
||||
|
||||
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 formatDate = (dateString: string | Date) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>Loading sessions...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className={styles.error}>{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.sessionManager}>
|
||||
<h2>Active Sessions</h2>
|
||||
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search sessions..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
className={styles.clearSearchButton}
|
||||
onClick={() => setSearchTerm('')}
|
||||
title="Clear search"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<p className={styles.noSessions}>No active sessions found.</p>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<p className={styles.noSessions}>No sessions match your search.</p>
|
||||
) : (
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.sessionsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device & Browser</th>
|
||||
<th>IP Address</th>
|
||||
<th>Last Active</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSessions.map((s) => (
|
||||
<tr key={s.id} className={s.isCurrentSession ? styles.currentSession : ''}>
|
||||
<td className={styles.deviceCell}>
|
||||
{s.userAgent.split(' ').slice(0, 3).join(' ')}
|
||||
</td>
|
||||
<td>{s.ipAddress}</td>
|
||||
<td>{formatDate(s.lastActive)}</td>
|
||||
<td>{formatDate(s.createdAt)}</td>
|
||||
<td>
|
||||
{!s.isCurrentSession ? (
|
||||
<button
|
||||
onClick={() => handleRevokeSession(s.id)}
|
||||
className={styles.revokeButton}
|
||||
disabled={revoking === s.id}
|
||||
>
|
||||
{revoking === s.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.currentSessionText}>Current Session</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
src/components/ui/dashboard/link/AnalyticsTable.module.css
Normal file
267
src/components/ui/dashboard/link/AnalyticsTable.module.css
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
.tableContainer {
|
||||
background-color: var(--card-bg);
|
||||
border-color: var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.analyticsTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.analyticsTable th {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.analyticsTable td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.analyticsTable tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.analyticsTable tr:hover td {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.expandedRow td {
|
||||
background-color: var(--hover-bg) !important;
|
||||
}
|
||||
|
||||
.secondaryInfo {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
width: 1.685rem;
|
||||
height: 1.685rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.expandButton:hover {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deleteButton:hover {
|
||||
background-color: var(--border-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.expandedDetails {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.detailsCard {
|
||||
background-color: var(--card-secondary-bg);
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detailsCard h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.detailsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detailsGrid > div {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detailsGrid strong {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pageButton {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pageButton:hover:not(:disabled) {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.pageButton:disabled {
|
||||
background-color: var(--disabled-bg);
|
||||
color: var(--disabled-text);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tableContainer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.analyticsTable th,
|
||||
.analyticsTable td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.detailsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.clearSearchButton {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.clearSearchButton:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.searchResults {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.noResults {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tableHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
262
src/components/ui/dashboard/link/AnalyticsTable.tsx
Normal file
262
src/components/ui/dashboard/link/AnalyticsTable.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Analytics } from '@/types/analytics';
|
||||
import styles from './AnalyticsTable.module.css';
|
||||
|
||||
interface AnalyticsTableProps {
|
||||
analytics: Analytics[];
|
||||
allAnalytics: Analytics[];
|
||||
totalItems: number;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onDeleteClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function AnalyticsTable({
|
||||
analytics,
|
||||
allAnalytics,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
onPageChange,
|
||||
onDeleteClick
|
||||
}: AnalyticsTableProps) {
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [searchResults, setSearchResults] = useState<Analytics[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = allAnalytics.filter(item => {
|
||||
return (
|
||||
// IP and location
|
||||
item.ip_address.toLowerCase().includes(query) ||
|
||||
item.ip_version.toLowerCase().includes(query) ||
|
||||
item.country.toLowerCase().includes(query) ||
|
||||
(item.ip_data?.isp && item.ip_data.isp.toLowerCase().includes(query)) ||
|
||||
|
||||
// Device and browser info
|
||||
item.platform.toLowerCase().includes(query) ||
|
||||
item.browser.toLowerCase().includes(query) ||
|
||||
item.version.toLowerCase().includes(query) ||
|
||||
item.language.toLowerCase().includes(query) ||
|
||||
|
||||
// Additional details
|
||||
item.user_agent.toLowerCase().includes(query) ||
|
||||
item.referrer.toLowerCase().includes(query) ||
|
||||
item.remote_port.toLowerCase().includes(query) ||
|
||||
item.accept?.toLowerCase().includes(query) ||
|
||||
item.accept_language?.toLowerCase().includes(query) ||
|
||||
item.accept_encoding?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
setSearchResults(filtered);
|
||||
}, [searchQuery, allAnalytics]);
|
||||
|
||||
const toggleRowExpansion = (id: string) => {
|
||||
const newExpandedRows = new Set(expandedRows);
|
||||
if (expandedRows.has(id)) {
|
||||
newExpandedRows.delete(id);
|
||||
} else {
|
||||
newExpandedRows.add(id);
|
||||
}
|
||||
setExpandedRows(newExpandedRows);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
|
||||
if (currentPage !== 1) {
|
||||
onPageChange(1);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
|
||||
if (currentPage !== 1) {
|
||||
onPageChange(1);
|
||||
}
|
||||
};
|
||||
|
||||
const displayedAnalytics = isSearching ? searchResults : analytics;
|
||||
|
||||
return (
|
||||
<div className={styles.tableContainer}>
|
||||
<div className={styles.tableHeader}>
|
||||
<h3 className={styles.tableTitle}>Click Details</h3>
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search analytics..."
|
||||
className={styles.searchInput}
|
||||
aria-label="Search analytics"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className={styles.clearSearchButton}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSearching && (
|
||||
<div className={styles.searchResults}>
|
||||
Found {searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.analyticsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>IP Address</th>
|
||||
<th>Location</th>
|
||||
<th>Device</th>
|
||||
<th>Browser</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayedAnalytics.map(item => {
|
||||
const id = item._id?.toString() || '';
|
||||
const isExpanded = expandedRows.has(id);
|
||||
|
||||
return (
|
||||
<tr key={id} className={isExpanded ? styles.expandedRow : ''}>
|
||||
<td>{new Date(item.timestamp).toLocaleString()}</td>
|
||||
<td>
|
||||
{item.ip_address}
|
||||
<div className={styles.secondaryInfo}>{item.ip_version}</div>
|
||||
</td>
|
||||
<td>
|
||||
{item.country}
|
||||
<div className={styles.secondaryInfo}>ISP: {item.ip_data?.isp || 'Unknown'}</div>
|
||||
</td>
|
||||
<td>
|
||||
{item.platform}
|
||||
</td>
|
||||
<td>
|
||||
{item.browser} {item.version}
|
||||
<div className={styles.secondaryInfo}>Lang: {item.language}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.expandButton}
|
||||
onClick={() => toggleRowExpansion(id)}
|
||||
aria-label={isExpanded ? "Collapse details" : "Expand details"}
|
||||
>
|
||||
{isExpanded ? '−' : '+'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.deleteButton}
|
||||
onClick={() => onDeleteClick(id)}
|
||||
aria-label="Delete entry"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{displayedAnalytics.length === 0 && (
|
||||
<div className={styles.noResults}>
|
||||
{isSearching
|
||||
? `No results found for "${searchQuery}"`
|
||||
: "No analytics data available"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expandedRows.size > 0 && (
|
||||
<div className={styles.expandedDetails}>
|
||||
{displayedAnalytics.map(item => {
|
||||
const id = item._id?.toString() || '';
|
||||
if (!expandedRows.has(id)) return null;
|
||||
|
||||
return (
|
||||
<div key={`details-${id}`} className={styles.detailsCard}>
|
||||
<h4>Additional Details</h4>
|
||||
<div className={styles.detailsGrid}>
|
||||
<div>
|
||||
<strong>User Agent:</strong>
|
||||
<div className={styles.detailValue}>{item.user_agent}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Referrer:</strong>
|
||||
<div className={styles.detailValue}>{item.referrer}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Remote Port:</strong>
|
||||
<div className={styles.detailValue}>{item.remote_port}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Accept:</strong>
|
||||
<div className={styles.detailValue}>{item.accept}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Accept-Language:</strong>
|
||||
<div className={styles.detailValue}>{item.accept_language}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Accept-Encoding:</strong>
|
||||
<div className={styles.detailValue}>{item.accept_encoding}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && !isSearching && (
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
className={styles.pageButton}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span className={styles.pageInfo}>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
className={styles.pageButton}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/contexts/ToastContext.tsx
Normal file
51
src/contexts/ToastContext.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useState, useContext, ReactNode } from 'react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
interface ToastMessage {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: ToastMessage[];
|
||||
showToast: (message: string, type?: ToastType) => void;
|
||||
hideToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
const showToast = (message: string, type: ToastType = 'info') => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const newToast = { id, message, type };
|
||||
setToasts((prev) => [...prev, newToast]);
|
||||
|
||||
setTimeout(() => {
|
||||
hideToast(id);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const hideToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, showToast, hideToast }}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
146
src/lib/analyticsdb.ts
Normal file
146
src/lib/analyticsdb.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { DetailedReturn } from '@/types/global';
|
||||
import { getMongo, Collection, safeObjectId } from './mongodb';
|
||||
import type { IPAddress, Analytics } from '@/types/analytics';
|
||||
|
||||
const ONE_WEEK = 1000 * 60 * 60 * 24 * 7;
|
||||
|
||||
export async function getIPData(ip: string): Promise<IPAddress> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<IPAddress>(Collection.ip_addresses_collection);
|
||||
|
||||
const existing = await collection.findOne({ ip_address: ip });
|
||||
|
||||
if (existing && Date.now() - new Date(existing.timestamp).getTime() < ONE_WEEK) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Fetch new data from ipwho.is
|
||||
const res = await fetch(`https://ipwho.is/${ip}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch IP data');
|
||||
const data = await res.json();
|
||||
|
||||
const ipData: IPAddress = {
|
||||
ip_address: ip,
|
||||
ip_version: ip.includes(':') ? 'IPv6' : 'IPv4',
|
||||
isp: data.connection?.isp || 'Unknown',
|
||||
country: data.country || 'Unknown',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
await collection.updateOne(
|
||||
{ ip_address: ip },
|
||||
{ $set: ipData },
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
return ipData;
|
||||
} catch {
|
||||
return {
|
||||
ip_address: ip,
|
||||
ip_version: ip.includes(':') ? 'IPv6' : 'IPv4',
|
||||
isp: 'Unknown',
|
||||
country: 'Unknown',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AnalyticsQueryOptions {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function getAllAnalytics(
|
||||
account_id: string,
|
||||
link_id: string,
|
||||
query_options: AnalyticsQueryOptions = {}): Promise<{analytics: Analytics[]; total: number}> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
|
||||
const { startDate, endDate, page = 1, limit = 50 } = query_options;
|
||||
const timestamp: Record<string, Date> = {};
|
||||
if (startDate) timestamp["$gte"] = startDate;
|
||||
if (endDate) timestamp["$lte"] = endDate;
|
||||
|
||||
// Overcomplicated shit
|
||||
const query: Omit<Partial<Analytics>, 'timestamp'> & { timestamp?: Record<string, Date> } = {
|
||||
account_id,
|
||||
link_id,
|
||||
};
|
||||
if (Object.keys(timestamp).length > 0) {
|
||||
query.timestamp = timestamp;
|
||||
}
|
||||
|
||||
const cursor = collection
|
||||
.find(query)
|
||||
.sort({ timestamp: -1 }) // Most recent first
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit);
|
||||
|
||||
const analytics = await cursor.toArray();
|
||||
const total = await collection.countDocuments(query);
|
||||
|
||||
return { analytics, total };
|
||||
} catch {
|
||||
return {analytics: [], total: 0};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAnalytics(analytics: Analytics): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
await collection.insertOne(analytics);
|
||||
|
||||
return { success: true, status: "Analytics successfully saved" };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAllAnalytics(account_id: string, link_id: string): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
const result = await collection.deleteMany({account_id: account_id, link_id: link_id});
|
||||
const success = result.deletedCount > 0;
|
||||
|
||||
return { success, status: success ? "Analytics were successfully deleted" : "No analytics found" };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAllAnalyticsFromUser(account_id: string): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
const result = await collection.deleteMany({account_id: account_id});
|
||||
const hasRemovedAnalytics = result.deletedCount > 0;
|
||||
|
||||
return { success: true, status: hasRemovedAnalytics ? "All analytics were successfully removed" : "No analytics found" };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAnalytics(account_id: string, link_id: string, _id: string): Promise<DetailedReturn> {
|
||||
const objectId = safeObjectId(_id);
|
||||
if(!objectId) return { success: false, status: "Invalid object ID" };
|
||||
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
await collection.deleteOne(
|
||||
{_id: objectId, account_id: account_id, link_id: link_id}
|
||||
);
|
||||
|
||||
return { success: true, status: "Analytics successfully removed" };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
}
|
||||
}
|
||||
131
src/lib/linkdb.ts
Normal file
131
src/lib/linkdb.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { getMongo, Collection } from './mongodb';
|
||||
import { generateLinkID, isValidUrl } from './utils';
|
||||
import type { Link } from '@/types/link';
|
||||
import type { DetailedReturn } from '@/types/global';
|
||||
|
||||
export async function getLinks(account_id: string): Promise<{links: Link[], return: DetailedReturn}> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
|
||||
const links = await collection.find({ account_id })
|
||||
.sort({ created_at: -1 })
|
||||
.toArray();
|
||||
|
||||
return { links, return: { success: true, status: "Links retrieved successfully" } };
|
||||
} catch {
|
||||
return { links: [], return: { success: false, status: "An exception occurred" } };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTargetUrl(short_id: string): Promise<{ target_url: string; account_id: string }> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
const found_link = await collection.findOne({short_id: short_id});
|
||||
|
||||
if (!found_link) return { target_url: "", account_id: "" };
|
||||
|
||||
return {
|
||||
target_url: found_link.target_url,
|
||||
account_id: found_link.account_id
|
||||
};
|
||||
} catch {
|
||||
return { target_url: "", account_id: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLinkById(account_id: string, short_id: string): Promise<{link: Link | null, return: DetailedReturn}> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
|
||||
const link = await collection.findOne({ short_id, account_id });
|
||||
|
||||
if (!link) {
|
||||
return { link: null, return: { success: false, status: "Link not found or you don't have permission to view it" } };
|
||||
}
|
||||
|
||||
return { link, return: { success: true, status: "Link retrieved successfully" } };
|
||||
} catch {
|
||||
return { link: null, return: { success: false, status: "An exception occurred" } };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLink(account_id: string, target_url: string): Promise<{shortId: string | null, return: DetailedReturn}> {
|
||||
try {
|
||||
if(!isValidUrl(target_url)) return { shortId: null, return: { success: false, status: "Invalid target URL" } };
|
||||
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
|
||||
let shortId;
|
||||
let duplicate = true;
|
||||
do {
|
||||
shortId = generateLinkID();
|
||||
const existing = await collection.findOne({ short_id: shortId });
|
||||
duplicate = existing !== null;
|
||||
} while (duplicate);
|
||||
|
||||
const newLink: Link = {
|
||||
short_id: shortId,
|
||||
target_url: target_url,
|
||||
account_id: account_id,
|
||||
created_at: new Date(),
|
||||
last_modified: new Date()
|
||||
}
|
||||
|
||||
await collection.insertOne(newLink);
|
||||
return { shortId, return: { success: true, status: "Link was successfully created" } };
|
||||
} catch {
|
||||
return { shortId: null, return: { success: false, status: "An exception occured" } };
|
||||
}
|
||||
}
|
||||
|
||||
export async function editLink(account_id: string, short_id: string, target_url: string): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
|
||||
const result = await collection.updateOne(
|
||||
{ account_id: account_id, short_id: short_id },
|
||||
{ $set: {
|
||||
target_url: target_url,
|
||||
last_modified: new Date()
|
||||
}}
|
||||
);
|
||||
|
||||
const success = result.modifiedCount > 0;
|
||||
|
||||
return { success, status: success ? "Link was successfully updated" : "Link not found" };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeLink(account_id: string, short_id: string): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
const result = await collection.deleteOne({account_id: account_id, short_id: short_id});
|
||||
const success = result.deletedCount > 0;
|
||||
|
||||
return { success, status: success ? "Link was successfully removed" : "Link not found" };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAllLinksFromUser(account_id: string): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
const result = await collection.deleteMany({account_id: account_id});
|
||||
const hasRemovedLinks = result.deletedCount > 0;
|
||||
|
||||
// Here it doesn't matter if no links were removed
|
||||
return { success: true, status: hasRemovedLinks ? "Links were successfully removed" : "No Links found" };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
}
|
||||
}
|
||||
22
src/lib/logger.ts
Normal file
22
src/lib/logger.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import winston from 'winston';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
}),
|
||||
|
||||
new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
||||
new winston.transports.File({ filename: 'combined.log' })
|
||||
]
|
||||
});
|
||||
|
||||
export default logger;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue