mirror of
https://github.com/Kizuren/uLinkShortener.git
synced 2025-12-21 21:16:17 +01:00
Compare commits
113 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f460ea3096 | ||
|
|
960db5f6d7 | ||
|
|
cb3b9eacff | ||
|
|
6e9e30f82c | ||
|
|
8729e57def | ||
|
|
47969209eb | ||
|
|
64028aeefb | ||
|
|
a07f904415 | ||
|
|
f955580abe | ||
|
|
4c47f592e6 | ||
|
|
d649a302a1 | ||
|
|
afa21e03d8 | ||
|
|
9239e01b5e | ||
|
|
f7a08abca2 | ||
|
|
33e21a0342 | ||
|
|
ae45b87191 | ||
|
|
7917fffe5b | ||
|
|
4f1b138763 | ||
|
|
d8f8c2d501 | ||
|
|
c8dd066869 | ||
|
|
ede439ece8 | ||
|
|
3d5f20bfb4 | ||
|
|
706a8e7cd6 | ||
|
|
36ccda9653 | ||
|
|
6daa3bebc7 | ||
|
|
632ae6cb84 | ||
|
|
4afaddaecb | ||
|
|
3cec133edf | ||
|
|
0c2075ba5c | ||
|
|
2c27188169 | ||
|
|
8969d3ae81 | ||
|
|
f10f67f04a | ||
|
|
589d5913a7 | ||
|
|
5fd61f2e69 | ||
|
|
fc71fb11d8 | ||
|
|
c29e885258 | ||
|
|
ec86a3a8d2 | ||
|
|
0cb6a51019 | ||
|
|
e02dbe94b0 | ||
|
|
4fc4219232 | ||
|
|
e533cf6f55 | ||
|
|
2e51776031 | ||
|
|
d0be106261 | ||
|
|
2c24ac0347 | ||
|
|
e5409572b4 | ||
|
|
438eb92131 | ||
|
|
4b6819371b | ||
|
|
cf0f0f2a1c | ||
|
|
199b5f7d72 | ||
|
|
3dc7955010 | ||
|
|
0121732a5a | ||
|
|
d27b0db28e | ||
|
|
592949f3dc | ||
|
|
ccfba73e51 | ||
|
|
82bc31b927 | ||
|
|
12b6d9ee4b | ||
|
|
3cfa6256e8 | ||
|
|
fb5459f274 | ||
|
|
91ef49a0b2 | ||
|
|
14622873f3 | ||
|
|
34d35ac349 | ||
|
|
3c36209e61 | ||
|
|
3b28d26c88 | ||
|
|
9dabc58cb1 | ||
|
|
089c86e9bc | ||
|
|
6acefa6062 | ||
|
|
20e175eeac | ||
|
|
03a40e03ea | ||
|
|
b476f1f5fa | ||
|
|
6493ca0783 | ||
|
|
6f52fa4208 | ||
|
|
9bbb924f48 | ||
|
|
988b0f25e6 | ||
|
|
7c953d4e64 | ||
|
|
3dc026026e | ||
|
|
b32a7a527c | ||
|
|
3b2a64793e | ||
|
|
d7b918817b | ||
|
|
dc677e5908 | ||
|
|
f45454764b | ||
|
|
04dbfe7c06 | ||
|
|
bdda7ca33f | ||
|
|
30fe2c0e4d | ||
|
|
586901ebed | ||
|
|
a3f542a7f8 | ||
|
|
9cf0a294ad | ||
|
|
71bdd08da9 | ||
|
|
ef76ad92ec | ||
|
|
ec50bba19b | ||
|
|
38788eed26 | ||
|
|
77d541553d | ||
|
|
f8e49d5f6a | ||
|
|
ab484dd0b4 | ||
|
|
73809f7f0f | ||
|
|
82c32f25b7 | ||
|
|
13bce357c6 | ||
|
|
a4ff5216ef | ||
|
|
ddeffc2504 | ||
|
|
1330ae152b | ||
|
|
24f53a379d | ||
|
|
edb583c6b2 | ||
|
|
3eca1505eb | ||
|
|
30f11d49f6 | ||
|
|
90e183941c | ||
|
|
d88cb5cdd3 | ||
|
|
1474edf7fe | ||
|
|
1a173a699a | ||
|
|
bc1371a7c9 | ||
|
|
b17e528180 | ||
|
|
b131756235 | ||
|
|
e86ee24506 | ||
|
|
f941eb54f1 | ||
|
|
2718e3f8f2 |
104 changed files with 11022 additions and 1007 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 +1,4 @@
|
|||
MONGO_URI=mongodb://localhost:27017/uLinkShortener
|
||||
MONGO_URI=mongodb://<username>:<password>@<host>:<port>/<database>
|
||||
MONGO_DB_NAME=<database>
|
||||
NEXTAUTH_SECRET=VERY_SECURE_SECRET
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
32
.github/dependabot.yml
vendored
Normal file
32
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
version: 2
|
||||
updates:
|
||||
# Monitor npm dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: auto
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "npm"
|
||||
|
||||
# Monitor Docker dependencies
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
|
||||
# Monitor GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
115
.github/workflows/build.yml
vendored
Normal file
115
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
# Update lockfile for Dependabot PRs
|
||||
update-lockfile:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'dependabot/')
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- name: Install and update lockfile
|
||||
run: bun install
|
||||
- name: Commit and push updated lockfile
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add bun.lock
|
||||
git commit -m "chore: update lockfile (auto-fix for Dependabot PR)" || exit 0
|
||||
git push
|
||||
|
||||
# Build and push Docker image for Dependabot PRs
|
||||
build_dependabot:
|
||||
runs-on: ubuntu-latest
|
||||
needs: update-lockfile
|
||||
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'dependabot/')
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,format=short
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: false
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
# Build and push Docker image for all other events
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || (github.event_name == 'pull_request' && !startsWith(github.head_ref, 'dependabot/'))
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,format=short
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
34
.github/workflows/release.yml
vendored
34
.github/workflows/release.yml
vendored
|
|
@ -1,34 +0,0 @@
|
|||
name: Build on Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/marcus7i/ulinkshortener:latest
|
||||
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
|
||||
30
Dockerfile
30
Dockerfile
|
|
@ -1,20 +1,22 @@
|
|||
FROM python:3.11-slim
|
||||
FROM oven/bun:1 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements first to leverage Docker cache
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=server.py
|
||||
ENV FLASK_ENV=production
|
||||
RUN bun run build
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
FROM oven/bun:1-slim AS runner
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "server.py"]
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["bunx", "next", "start"]
|
||||
|
|
|
|||
62
README.md
62
README.md
|
|
@ -1,47 +1,43 @@
|
|||
# uLinkShortener
|
||||
# µLinkShortener v2
|
||||
|
||||
This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a custom URL shortener. It uses Flask, MongoDB, and Docker for quick deployment.
|
||||
This project is the code behind [u.kizuren.dev](https://u.kizuren.dev), a custom URL shortener. It uses Next.JS, MongoDB, and Docker for quick deployment.
|
||||
|
||||
## Prerequisites
|
||||
- Python
|
||||
- MongoDB database (local or remote)
|
||||
- Docker & Docker Compose (optional, for containerized deployments)
|
||||
- bun (optional, for development)
|
||||
- Docker & Docker Compose
|
||||
|
||||
## Setup
|
||||
1. Clone the repository
|
||||
2. Create a virtual environment (optional):
|
||||
```
|
||||
python -m venv env
|
||||
source env/bin/activate # Linux/Mac
|
||||
env\Scripts\activate # Windows
|
||||
```
|
||||
3. Install dependencies:
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
4. Define environment variables in the `.env` file:
|
||||
4. Define environment variables in the `.env` file (mongo connection string is not needed when using docker):
|
||||
```
|
||||
MONGO_URI=mongodb://<username>:<password>@<host>:<port>/<database>
|
||||
MONGO_DB_NAME=<database>
|
||||
NEXTAUTH_SECRET=VERY_SECURE_SECRET
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
## Running Locally
|
||||
1. Start MongoDB
|
||||
2. Run:
|
||||
```
|
||||
python server.py
|
||||
```
|
||||
3. Access the app at http://localhost:5000
|
||||
|
||||
## Docker Deployment
|
||||
1. Build and run containers:
|
||||
```
|
||||
docker-compose up --build
|
||||
```
|
||||
2. The application will be available at http://localhost:5000
|
||||
### Without Docker
|
||||
|
||||
## Using GHCR
|
||||
1. Install dependencies:
|
||||
```
|
||||
bun i
|
||||
```
|
||||
2. Build and run:
|
||||
```
|
||||
bun run build
|
||||
bun run start
|
||||
```
|
||||
|
||||
Pull the prebuilt image:
|
||||
```bash
|
||||
docker pull ghcr.io/MarcUs7i/ulinkshortener:latest
|
||||
```
|
||||
### With Docker
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
docker compose up --build
|
||||
```
|
||||
The application will be available at http://localhost:3000
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
106
biome.json
Normal file
106
biome.json
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": [
|
||||
"**",
|
||||
"!.next",
|
||||
"!.log",
|
||||
"next-env.d.ts",
|
||||
"!node_modules",
|
||||
"!dist",
|
||||
"!build",
|
||||
"!coverage",
|
||||
"!*.min.js",
|
||||
"!.env*",
|
||||
"!*.log",
|
||||
"!tmp",
|
||||
"!temp",
|
||||
"!*.config.js"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noExtraBooleanCast": "error",
|
||||
"noUselessCatch": "error",
|
||||
"noUselessConstructor": "error",
|
||||
"noUselessLabel": "error",
|
||||
"noUselessRename": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noConstAssign": "error",
|
||||
"noConstantCondition": "error",
|
||||
"noEmptyPattern": "error",
|
||||
"noGlobalObjectCalls": "error",
|
||||
"noInnerDeclarations": "error",
|
||||
"noInvalidConstructorSuper": "error",
|
||||
"noUnreachable": "error",
|
||||
"noUnreachableSuper": "error",
|
||||
"noUnsafeFinally": "error",
|
||||
"noUnusedLabels": "error",
|
||||
"noUnusedVariables": "warn",
|
||||
"useIsNan": "error"
|
||||
},
|
||||
"style": {
|
||||
"useConst": "warn",
|
||||
"useTemplate": "warn"
|
||||
},
|
||||
"suspicious": {
|
||||
"noAsyncPromiseExecutor": "error",
|
||||
"noCatchAssign": "error",
|
||||
"noClassAssign": "error",
|
||||
"noCommentText": "error",
|
||||
"noCompareNegZero": "error",
|
||||
"noDebugger": "warn",
|
||||
"noDoubleEquals": "warn",
|
||||
"noDuplicateCase": "error",
|
||||
"noDuplicateClassMembers": "error",
|
||||
"noDuplicateObjectKeys": "error",
|
||||
"noEmptyBlockStatements": "warn",
|
||||
"noExplicitAny": "warn",
|
||||
"noFallthroughSwitchClause": "error",
|
||||
"noGlobalAssign": "error",
|
||||
"noRedeclare": "error",
|
||||
"noShadowRestrictedNames": "error",
|
||||
"noUnsafeNegation": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "single",
|
||||
"semicolons": "always",
|
||||
"trailingCommas": "es5",
|
||||
"arrowParentheses": "asNeeded"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
}
|
||||
}
|
||||
387
bun.lock
Normal file
387
bun.lock
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ulinkshortener",
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"mongodb": "^7.0.0",
|
||||
"next": "16.0.8",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "^19.2.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"ua-parser-js": "^2.0.6",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.19.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.8",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/node": "^24.10.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="],
|
||||
|
||||
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
|
||||
|
||||
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.3" }, "os": "darwin", "cpu": "x64" }, "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.3" }, "os": "linux", "cpu": "arm" }, "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.3" }, "os": "linux", "cpu": "s390x" }, "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.4", "", { "dependencies": { "@emnapi/runtime": "^1.5.0" }, "cpu": "none" }, "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.4", "", { "os": "win32", "cpu": "x64" }, "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||
|
||||
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.3.0", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ=="],
|
||||
|
||||
"@next/env": ["@next/env@16.0.8", "", {}, "sha512-xP4WrQZuj9MdmLJy3eWFHepo+R3vznsMSS8Dy3wdA7FKpjCiesQ6DxZvdGziQisj0tEtCgBKJzjcAc4yZOgLEQ=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yjVMvTQN21ZHOclQnhSFbjBTEizle+1uo4NV6L4rtS9WO3nfjaeJYw+H91G+nEf3Ef43TaEZvY5mPWfB/De7tA=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-+zu2N3QQ0ZOb6RyqQKfcu/pn0UPGmg+mUDqpAAEviAcEVEYgDckemOpiMRsBP3IsEKpcoKuNzekDcPczEeEIzA=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-LConttk+BeD0e6RG0jGEP9GfvdaBVMYsLJ5aDDweKiJVVCu6sGvo+Ohz9nQhvj7EQDVVRJMCGhl19DmJwGr6bQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-JaXFAlqn8fJV+GhhA9lpg6da/NCN/v9ub98n3HoayoUSPOVdoxEEt86iT58jXqQCs/R3dv5ZnxGkW8aF4obMrQ=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-O7M9it6HyNhsJp3HNAsJoHk5BUsfj7hRshfptpGcVsPZ1u0KQ/oVy8oxF7tlwxA5tR43VUP0yRmAGm1us514ng=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-8+KClEC/GLI2dLYcrWwHu5JyC5cZYCFnccVIvmxpo6K+XQt4qzqM5L4coofNDZYkct/VCCyJWGbZZDsg6w6LFA=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-rpQ/PgTEgH68SiXmhu/cJ2hk9aZ6YgFvspzQWe2I9HufY6g7V02DXRr/xrVqOaKm2lenBFPNQ+KAaeveywqV+A=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.8", "", { "os": "win32", "cpu": "x64" }, "sha512-jWpWjWcMQu2iZz4pEK2IktcfR+OA9+cCG8zenyLpcW8rN4rzjfOzH4yj/b1FiEAZHKS+5Vq8+bZyHi+2yqHbFA=="],
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
"@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
|
||||
|
||||
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
|
||||
|
||||
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
|
||||
|
||||
"@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"bson": ["bson@7.0.0", "", {}, "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
|
||||
|
||||
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
|
||||
|
||||
"color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="],
|
||||
|
||||
"color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="],
|
||||
|
||||
"color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"detect-europe-js": ["detect-europe-js@0.1.2", "", {}, "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||
|
||||
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
|
||||
|
||||
"fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="],
|
||||
|
||||
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||
|
||||
"logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
|
||||
|
||||
"mongodb": ["mongodb@7.0.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.0.0", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg=="],
|
||||
|
||||
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.0", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"next": ["next@16.0.8", "", { "dependencies": { "@next/env": "16.0.8", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.8", "@next/swc-darwin-x64": "16.0.8", "@next/swc-linux-arm64-gnu": "16.0.8", "@next/swc-linux-arm64-musl": "16.0.8", "@next/swc-linux-x64-gnu": "16.0.8", "@next/swc-linux-x64-musl": "16.0.8", "@next/swc-win32-arm64-msvc": "16.0.8", "@next/swc-win32-x64-msvc": "16.0.8", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-LmcZzG04JuzNXi48s5P+TnJBsTGPJunViNKV/iE4uM6kstjTQsQhvsAv+xF6MJxU2Pr26tl15eVbp0jQnsv6/g=="],
|
||||
|
||||
"next-auth": ["next-auth@4.24.13", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^8.3.2" }, "peerDependencies": { "@auth/core": "0.34.3", "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", "nodemailer": "^7.0.7", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ=="],
|
||||
|
||||
"oauth": ["oauth@0.9.15", "", {}, "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="],
|
||||
|
||||
"object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="],
|
||||
|
||||
"oidc-token-hash": ["oidc-token-hash@5.1.0", "", {}, "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA=="],
|
||||
|
||||
"one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
|
||||
|
||||
"openid-client": ["openid-client@5.7.1", "", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.5", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg=="],
|
||||
|
||||
"preact": ["preact@10.26.9", "", {}, "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA=="],
|
||||
|
||||
"preact-render-to-string": ["preact-render-to-string@5.2.6", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw=="],
|
||||
|
||||
"pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||
|
||||
"react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="],
|
||||
|
||||
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
|
||||
|
||||
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
|
||||
|
||||
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
||||
|
||||
"triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="],
|
||||
|
||||
"ua-parser-js": ["ua-parser-js@2.0.7", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-CFdHVHr+6YfbktNZegH3qbYvYgC7nRNEUm2tk7nSFXSODUu4tDBpaFpP1jdXBUOKKwapVlWRfTtS8bCPzsQ47w=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
|
||||
|
||||
"winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="],
|
||||
|
||||
"winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@types/uuid/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"next-auth/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
}
|
||||
}
|
||||
19
docker-compose.dev.yml
Normal file
19
docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
services:
|
||||
mongo:
|
||||
image: mongo:latest
|
||||
container_name: ulinkshortener-mongo-dev
|
||||
restart: always
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: justauser
|
||||
MONGO_INITDB_ROOT_PASSWORD: veryimportantpasswd
|
||||
MONGO_INITDB_DATABASE: ulinkshortener
|
||||
command: mongod --bind_ip_all
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
- mongo_config:/data/configdb
|
||||
ports:
|
||||
- "27017:27017"
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
mongo_config:
|
||||
|
|
@ -1,33 +1,33 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ulinkshortener:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ghcr.io/kizuren/ulinkshortener
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- MONGO_URI=mongodb://mongo:27017/uLinkShortener
|
||||
depends_on:
|
||||
- mongo
|
||||
- MONGO_URI=mongodb://justauser:veryimportantpasswd@mongo:27017/ulinkshortener?authSource=admin
|
||||
- MONGO_DB_NAME=ulinkshortener
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
mongo:
|
||||
image: mongo:latest
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
ports:
|
||||
- "27017:27017"
|
||||
networks:
|
||||
- app-network
|
||||
container_name: ulinkshortener-mongo
|
||||
restart: always
|
||||
environment:
|
||||
- MONGO_INITDB_DATABASE=uLinkShortener
|
||||
restart: unless-stopped
|
||||
MONGO_INITDB_ROOT_USERNAME: justauser
|
||||
MONGO_INITDB_ROOT_PASSWORD: veryimportantpasswd
|
||||
MONGO_INITDB_DATABASE: ulinkshortener
|
||||
command: mongod --bind_ip_all
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
- mongo_config:/data/configdb
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
mongo_data:
|
||||
mongo_config:
|
||||
|
|
|
|||
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;
|
||||
35
package.json
Normal file
35
package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "ulinkshortener",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome lint",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format . --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"mongodb": "^7.0.0",
|
||||
"next": "16.0.8",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "^19.2.3",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"ua-parser-js": "^2.0.7",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.8",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^24.10.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const config = {
|
||||
plugins: ['@tailwindcss/postcss'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
|
|
@ -1,4 +0,0 @@
|
|||
flask
|
||||
flask-pymongo
|
||||
python-dotenv
|
||||
requests
|
||||
182
server.py
182
server.py
|
|
@ -1,182 +0,0 @@
|
|||
from flask import Flask, request, redirect, render_template, jsonify, make_response
|
||||
from flask_pymongo import PyMongo
|
||||
from datetime import datetime
|
||||
import random
|
||||
import string
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import json
|
||||
from urllib.parse import quote_plus, urlparse
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__, template_folder='template', static_folder='static', static_url_path='/static')
|
||||
app.config["MONGO_URI"] = os.getenv("MONGO_URI")
|
||||
mongo = PyMongo(app)
|
||||
|
||||
def generate_short_id():
|
||||
return ''.join(random.choices(string.ascii_letters + string.digits, k=8))
|
||||
|
||||
def get_client_info():
|
||||
user_agent = request.user_agent
|
||||
ip_address = (request.headers.get('CF-Connecting-IP') or
|
||||
request.headers.get('X-Real-IP') or
|
||||
request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or
|
||||
request.remote_addr or
|
||||
'Unknown')
|
||||
|
||||
return {
|
||||
'ip': ip_address,
|
||||
'user_agent': user_agent.string,
|
||||
'platform': request.headers.get('sec-ch-ua-platform', user_agent.platform or 'Unknown'),
|
||||
'browser': user_agent.browser or 'Unknown',
|
||||
'version': user_agent.version or '',
|
||||
'language': request.accept_languages.best or 'Unknown',
|
||||
'referrer': request.referrer or 'Direct',
|
||||
'timestamp': datetime.now(),
|
||||
'remote_port': request.environ.get('REMOTE_PORT', 'Unknown'),
|
||||
'accept': request.headers.get('Accept', 'Unknown'),
|
||||
'accept_language': request.headers.get('Accept-Language', 'Unknown'),
|
||||
'accept_encoding': request.headers.get('Accept-Encoding', 'Unknown'),
|
||||
'screen_size': request.headers.get('Sec-CH-UA-Platform-Screen', 'Unknown'),
|
||||
'window_size': request.headers.get('Viewport-Width', 'Unknown'),
|
||||
'country': request.headers.get('CF-IPCountry', 'Unknown'), # If using Cloudflare
|
||||
'isp': request.headers.get('X-ISP', 'Unknown'), # Requires additional middleware
|
||||
'ip_version': 'IPv6' if ':' in ip_address else 'IPv4'
|
||||
}
|
||||
|
||||
def is_valid_url(url):
|
||||
if not url or url.isspace():
|
||||
return False
|
||||
try:
|
||||
result = urlparse(url)
|
||||
return all([result.scheme, result.netloc])
|
||||
except:
|
||||
return False
|
||||
|
||||
@app.route('/')
|
||||
def home():
|
||||
account_id = request.cookies.get('account_id')
|
||||
|
||||
stats = {
|
||||
'total_links': mongo.db.links.count_documents({}),
|
||||
'total_clicks': mongo.db.analytics.count_documents({}),
|
||||
'chart_data': {
|
||||
'ip_versions': list(mongo.db.analytics.aggregate([
|
||||
{"$group": {"_id": "$ip_version", "count": {"$sum": 1}}},
|
||||
{"$sort": {"count": -1}}
|
||||
])),
|
||||
'os_stats': list(mongo.db.analytics.aggregate([
|
||||
{"$group": {"_id": "$platform", "count": {"$sum": 1}}},
|
||||
{"$sort": {"count": -1}},
|
||||
{"$limit": 10}
|
||||
])),
|
||||
'country_stats': list(mongo.db.analytics.aggregate([
|
||||
{"$group": {"_id": "$country", "count": {"$sum": 1}}},
|
||||
{"$sort": {"count": -1}},
|
||||
{"$limit": 10}
|
||||
])),
|
||||
'isp_stats': list(mongo.db.analytics.aggregate([
|
||||
{"$group": {"_id": "$isp", "count": {"$sum": 1}}},
|
||||
{"$sort": {"count": -1}},
|
||||
{"$limit": 10}
|
||||
]))
|
||||
},
|
||||
'logged_in': bool(account_id)
|
||||
}
|
||||
return render_template('index.html', stats=stats)
|
||||
|
||||
@app.route('/register', methods=['POST'])
|
||||
def register():
|
||||
account_id = ''.join(random.choices(string.digits, k=8))
|
||||
while mongo.db.users.find_one({'account_id': account_id}):
|
||||
account_id = ''.join(random.choices(string.digits, k=8))
|
||||
|
||||
mongo.db.users.insert_one({'account_id': account_id})
|
||||
response = make_response(jsonify({'account_id': account_id}))
|
||||
response.set_cookie('account_id', account_id, max_age=31536000)
|
||||
return response
|
||||
|
||||
@app.route('/login', methods=['POST'])
|
||||
def login():
|
||||
account_id = request.json.get('account_id')
|
||||
user = mongo.db.users.find_one({'account_id': account_id})
|
||||
if user:
|
||||
response = make_response(jsonify({'success': True}))
|
||||
response.set_cookie('account_id', account_id, max_age=31536000)
|
||||
return response
|
||||
return jsonify({'success': False}), 401
|
||||
|
||||
@app.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
response = make_response(jsonify({'success': True}))
|
||||
response.delete_cookie('account_id')
|
||||
return response
|
||||
|
||||
@app.route('/create', methods=['POST'])
|
||||
def create_link():
|
||||
account_id = request.json.get('account_id')
|
||||
target_url = request.json.get('url')
|
||||
|
||||
if not mongo.db.users.find_one({'account_id': account_id}):
|
||||
return jsonify({'error': 'Invalid account'}), 401
|
||||
|
||||
if not is_valid_url(target_url):
|
||||
return jsonify({'error': 'Invalid URL. Please provide a valid URL with scheme (e.g., http:// or https://)'}), 400
|
||||
|
||||
short_id = generate_short_id()
|
||||
mongo.db.links.insert_one({
|
||||
'short_id': short_id,
|
||||
'target_url': target_url,
|
||||
'account_id': account_id,
|
||||
'created_at': datetime.now()
|
||||
})
|
||||
|
||||
return jsonify({'short_url': f'/l/{short_id}'})
|
||||
|
||||
@app.route('/l/<short_id>')
|
||||
def redirect_link(short_id):
|
||||
link = mongo.db.links.find_one({'short_id': short_id})
|
||||
if not link:
|
||||
return 'Link not found', 404
|
||||
|
||||
client_info = get_client_info()
|
||||
mongo.db.analytics.insert_one({
|
||||
'link_id': short_id,
|
||||
'account_id': link['account_id'],
|
||||
**client_info
|
||||
})
|
||||
|
||||
return redirect(link['target_url'])
|
||||
|
||||
@app.route('/analytics/<account_id>')
|
||||
def get_analytics(account_id):
|
||||
if not mongo.db.users.find_one({'account_id': account_id}):
|
||||
return jsonify({'error': 'Invalid account'}), 401
|
||||
|
||||
links = list(mongo.db.links.find({'account_id': account_id}, {'_id': 0}))
|
||||
analytics = list(mongo.db.analytics.find({'account_id': account_id}, {'_id': 0}))
|
||||
|
||||
return jsonify({
|
||||
'links': links,
|
||||
'analytics': analytics
|
||||
})
|
||||
|
||||
@app.route('/delete/<short_id>', methods=['DELETE'])
|
||||
def delete_link(short_id):
|
||||
account_id = request.cookies.get('account_id')
|
||||
if not account_id:
|
||||
return jsonify({'error': 'Not logged in'}), 401
|
||||
|
||||
link = mongo.db.links.find_one({'short_id': short_id, 'account_id': account_id})
|
||||
if not link:
|
||||
return jsonify({'error': 'Link not found or unauthorized'}), 404
|
||||
|
||||
# Delete the link and its analytics
|
||||
mongo.db.links.delete_one({'short_id': short_id})
|
||||
mongo.db.analytics.delete_many({'link_id': short_id})
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host="0.0.0.0", port=5000)
|
||||
172
src/app/Home.module.css
Normal file
172
src/app/Home.module.css
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/* Homepage specific styles */
|
||||
.default-container {
|
||||
width: 100%;
|
||||
max-width: 90%;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.default-container {
|
||||
max-width: 80%;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.default-container {
|
||||
max-width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero section */
|
||||
.hero-section {
|
||||
min-height: 25vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 3rem 0 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(90deg, var(--accent), #9089fc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hero-cta {
|
||||
display: inline-block;
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.hero-cta:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Stats section */
|
||||
.stats-summary {
|
||||
margin-bottom: 2rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 2rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
border: 1px solid var(--border-color);
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.stats-card h3 {
|
||||
font-size: 1rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stats-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.stats-number {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Graphs section */
|
||||
.graphs-section {
|
||||
padding: 2rem 0 4rem;
|
||||
}
|
||||
|
||||
.graphs-title {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graphs-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.graph-card {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--border-color);
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
height: 20rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.graph-title {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.graph-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.graphs-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.default-container {
|
||||
max-width: max-content;
|
||||
}
|
||||
}
|
||||
234
src/app/admin/AdminDashboard.module.css
Normal file
234
src/app/admin/AdminDashboard.module.css
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
.adminContainer {
|
||||
max-width: 75rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.adminHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.adminTitle {
|
||||
font-size: 2rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboardButton,
|
||||
.statsButton {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.dashboardButton {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.statsButton {
|
||||
background-color: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dashboardButton:hover,
|
||||
.statsButton:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.statsButton:disabled {
|
||||
background-color: var(--disabled-bg);
|
||||
color: var(--disabled-text);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.usersSection {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-right: 2rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.clearSearchButton {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.usersTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.usersTable th,
|
||||
.usersTable td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.usersTable th {
|
||||
background-color: var(--table-header-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.usersTable tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.usersTable tr:hover td {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.viewButton,
|
||||
.deleteButton {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.viewButton {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.viewButton:hover,
|
||||
.deleteButton:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.makeAdminButton,
|
||||
.removeAdminButton {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.makeAdminButton {
|
||||
background-color: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.removeAdminButton {
|
||||
background-color: var(--warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.makeAdminButton:hover,
|
||||
.removeAdminButton:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.adminHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
277
src/app/admin/page.tsx
Normal file
277
src/app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import ConfirmModal from '@/components/ui/ConfirmModal';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import styles from './AdminDashboard.module.css';
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [users, setUsers] = useState<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>
|
||||
);
|
||||
}
|
||||
178
src/app/admin/user/[accountId]/UserDetail.module.css
Normal file
178
src/app/admin/user/[accountId]/UserDetail.module.css
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
.container {
|
||||
max-width: 75rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.backButton,
|
||||
.deleteButton {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.backButton:hover,
|
||||
.deleteButton:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.userInfo,
|
||||
.linksSection,
|
||||
.sessionsSection {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
background-color: var(--card-secondary-bg);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.infoRow:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
width: 120px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.noItems {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sessionsTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.sessionsTable th,
|
||||
.sessionsTable td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sessionsTable th {
|
||||
background-color: var(--table-header-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sessionsTable tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sessionsTable tr:hover td {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.revokeButton {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.revokeButton:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.revokeButton:disabled {
|
||||
background-color: var(--disabled-bg);
|
||||
color: var(--disabled-text);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--error);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
573
src/app/admin/user/[accountId]/links/[shortId]/page.tsx
Normal file
573
src/app/admin/user/[accountId]/links/[shortId]/page.tsx
Normal file
|
|
@ -0,0 +1,573 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import ConfirmModal from '@/components/ui/ConfirmModal';
|
||||
import AnalyticsTable from '@/components/ui/dashboard/link/AnalyticsTable';
|
||||
import Graph from '@/components/ui/Graph';
|
||||
import styles from './AdminLinkDetail.module.css';
|
||||
import type { Link as LinkType } from '@/types/link';
|
||||
import type { Analytics } from '@/types/analytics';
|
||||
import type { StatItem } from '@/types/statistics';
|
||||
|
||||
export default function AdminLinkDetailPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const accountId = params.accountId as string;
|
||||
const shortId = params.shortId as string;
|
||||
|
||||
const [link, setLink] = useState<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>
|
||||
);
|
||||
}
|
||||
236
src/app/admin/user/[accountId]/page.tsx
Normal file
236
src/app/admin/user/[accountId]/page.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import Link from 'next/link';
|
||||
import AdminLinkTable from '@/components/ui/admin/AdminLinkTable';
|
||||
import ConfirmModal from '@/components/ui/ConfirmModal';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import styles from './UserDetail.module.css';
|
||||
import type { User } from '@/types/user';
|
||||
import type { SessionInfo } from '@/types/session';
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
const { showToast } = useToast();
|
||||
const accountId = params.accountId as string;
|
||||
|
||||
const [user, setUser] = useState<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>
|
||||
);
|
||||
}
|
||||
37
src/app/api/admin/statistics/rebuild/route.ts
Normal file
37
src/app/api/admin/statistics/rebuild/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { updateStats } from '@/lib/statisticsdb';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await updateStats();
|
||||
|
||||
return NextResponse.json({
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error rebuilding statistics:', { error });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to rebuild statistics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
82
src/app/api/admin/users/[accountId]/admin/route.ts
Normal file
82
src/app/api/admin/users/[accountId]/admin/route.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { isUserAdmin, makeUserAdmin } from '@/lib/userdb';
|
||||
import { removeAllSessionsByAccountId } from '@/lib/sessiondb';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { accountId } = await params;
|
||||
|
||||
if (!accountId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (accountId === session.user.accountId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'You cannot change your own admin status',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isAdmin = await isUserAdmin(accountId);
|
||||
const result = await makeUserAdmin(accountId, !isAdmin);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: false,
|
||||
},
|
||||
{ status: result.status === 'User not found' ? 404 : 500 }
|
||||
);
|
||||
}
|
||||
|
||||
await removeAllSessionsByAccountId(accountId);
|
||||
|
||||
logger.info(`Admin status toggled for user ${accountId}, all sessions revoked`, {
|
||||
accountId,
|
||||
newStatus: !isAdmin,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: `${result.status} User will need to log in again.`,
|
||||
is_admin: !isAdmin,
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error toggling admin status:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to toggle admin status',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getAllAnalytics, removeAnalytics, removeAllAnalytics } from '@/lib/analyticsdb';
|
||||
import { sanitizeMongoDocument } from '@/lib/utils';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string; shortId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { accountId, shortId } = await params;
|
||||
|
||||
if (!accountId || !shortId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID and Short ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50');
|
||||
|
||||
const { analytics, total } = await getAllAnalytics(accountId, shortId, { page, limit });
|
||||
|
||||
const sanitizedAnalytics = analytics.map(item => sanitizeMongoDocument(item));
|
||||
|
||||
return NextResponse.json({
|
||||
analytics: sanitizedAnalytics,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
pages: Math.ceil(total / limit),
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting analytics:', {
|
||||
error,
|
||||
accountId: (await params).accountId,
|
||||
shortId: (await params).shortId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve analytics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string; shortId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { accountId, shortId } = await params;
|
||||
|
||||
if (!accountId || !shortId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID and Short ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
if (body.delete_all) {
|
||||
const result = await removeAllAnalytics(accountId, shortId);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
},
|
||||
{ status: result.success ? 200 : 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (body.analytics_id) {
|
||||
const result = await removeAnalytics(accountId, shortId, body.analytics_id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
},
|
||||
{ status: result.success ? 200 : 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Either delete_all or analytics_id must be provided',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting analytics:', {
|
||||
error,
|
||||
accountId: (await params).accountId,
|
||||
shortId: (await params).shortId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to delete analytics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
188
src/app/api/admin/users/[accountId]/links/[shortId]/route.ts
Normal file
188
src/app/api/admin/users/[accountId]/links/[shortId]/route.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getLinkById, editLink, removeLink } from '@/lib/linkdb';
|
||||
import { sanitizeMongoDocument } from '@/lib/utils';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string; shortId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { accountId, shortId } = await params;
|
||||
|
||||
if (!accountId || !shortId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID and Short ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { link, return: linkReturn } = await getLinkById(accountId, shortId);
|
||||
|
||||
if (!linkReturn.success || !link) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: linkReturn.status,
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
link: sanitizeMongoDocument(link),
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting link details:', {
|
||||
error,
|
||||
accountId: (await params).accountId,
|
||||
shortId: (await params).shortId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve link details',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string; shortId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { accountId, shortId } = await params;
|
||||
|
||||
if (!accountId || !shortId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID and Short ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { target_url } = body;
|
||||
|
||||
if (!target_url) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Target URL is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await editLink(accountId, shortId, target_url);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
},
|
||||
{ status: result.success ? 200 : 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error updating link:', {
|
||||
error,
|
||||
accountId: (await params).accountId,
|
||||
shortId: (await params).shortId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to update link',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string; shortId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { accountId, shortId } = await params;
|
||||
|
||||
if (!accountId || !shortId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID and Short ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await removeLink(accountId, shortId);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
},
|
||||
{ status: result.success ? 200 : 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting link:', {
|
||||
error,
|
||||
accountId: (await params).accountId,
|
||||
shortId: (await params).shortId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to delete link',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/app/api/admin/users/[accountId]/links/route.ts
Normal file
54
src/app/api/admin/users/[accountId]/links/route.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getLinks } from '@/lib/linkdb';
|
||||
import { sanitizeMongoDocument } from '@/lib/utils';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { accountId } = await params;
|
||||
|
||||
if (!accountId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { links } = await getLinks(accountId);
|
||||
const sanitizedLinks = links.map(link => sanitizeMongoDocument(link));
|
||||
|
||||
return NextResponse.json({
|
||||
links: sanitizedLinks,
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting user links:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve user links',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
65
src/app/api/admin/users/[accountId]/route.ts
Normal file
65
src/app/api/admin/users/[accountId]/route.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getUserById } from '@/lib/userdb';
|
||||
import logger from '@/lib/logger';
|
||||
import { sanitizeMongoDocument } from '@/lib/utils';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { accountId } = await params;
|
||||
|
||||
if (!accountId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await getUserById(accountId);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'User not found',
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const sanitizedUser = sanitizeMongoDocument(user);
|
||||
|
||||
return NextResponse.json({
|
||||
user: sanitizedUser,
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting user:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve user',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
src/app/api/admin/users/[accountId]/sessions/revoke/route.ts
Normal file
48
src/app/api/admin/users/[accountId]/sessions/revoke/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { revokeSession } from '@/lib/sessiondb';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { sessionId, accountId } = await req.json();
|
||||
|
||||
if (!sessionId || !accountId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Session ID and Account ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await revokeSession(sessionId, accountId);
|
||||
|
||||
return NextResponse.json({
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error revoking session:', { error });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to revoke session',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
56
src/app/api/admin/users/[accountId]/sessions/route.ts
Normal file
56
src/app/api/admin/users/[accountId]/sessions/route.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getSessions } from '@/lib/sessiondb';
|
||||
import { sanitizeMongoDocument } from '@/lib/utils';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { accountId } = await params;
|
||||
|
||||
if (!accountId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const sessions = await getSessions(accountId);
|
||||
const sanitizedSessions = Array.isArray(sessions)
|
||||
? sessions.map(session => sanitizeMongoDocument(session))
|
||||
: [];
|
||||
|
||||
return NextResponse.json({
|
||||
sessions: sanitizedSessions,
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting user sessions:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve user sessions',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
115
src/app/api/admin/users/route.ts
Normal file
115
src/app/api/admin/users/route.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { listUsers, removeUser, isUserAdmin } from '@/lib/userdb';
|
||||
import { sanitizeMongoDocument } from '@/lib/utils';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await listUsers();
|
||||
|
||||
if (result.users) {
|
||||
const sanitizedUsers = result.users.map(user => sanitizeMongoDocument(user));
|
||||
|
||||
return NextResponse.json({
|
||||
users: sanitizedUsers,
|
||||
total: result.total,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.return.status,
|
||||
success: result.return.success,
|
||||
},
|
||||
{ status: result.return.success ? 200 : 500 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error getting users:', { error });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve users',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { account_id } = await req.json();
|
||||
|
||||
if (!account_id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (account_id === session.user.accountId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'You cannot delete your own account from admin panel',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (await isUserAdmin(account_id)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Cannot delete admin accounts',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await removeUser(account_id);
|
||||
|
||||
return NextResponse.json({
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error deleting user:', { error });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to delete user',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
240
src/app/api/analytics/route.ts
Normal file
240
src/app/api/analytics/route.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAllAnalytics, removeAnalytics, removeAllAnalytics } from '@/lib/analyticsdb';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
// View analytics for a specific link
|
||||
// Example: /api/analytics?link_id=SHORT_ID&page=1&limit=50&startDate=2025-01-01&endDate=2025-12-31
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Analytics request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
const url = new URL(req.url);
|
||||
|
||||
const link_id = url.searchParams.get('link_id');
|
||||
if (!link_id) {
|
||||
logger.info('Analytics request failed due to missing link_id', { account_id, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing link_id parameter',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const all = url.searchParams.get('all') === 'true';
|
||||
|
||||
let page = 1;
|
||||
let limit = 50;
|
||||
|
||||
if (!all) {
|
||||
page = parseInt(url.searchParams.get('page') || '1');
|
||||
limit = parseInt(url.searchParams.get('limit') || '50');
|
||||
}
|
||||
|
||||
let startDate: Date | undefined;
|
||||
let endDate: Date | undefined;
|
||||
|
||||
if (url.searchParams.get('startDate')) {
|
||||
startDate = new Date(url.searchParams.get('startDate')!);
|
||||
}
|
||||
|
||||
if (url.searchParams.get('endDate')) {
|
||||
endDate = new Date(url.searchParams.get('endDate')!);
|
||||
}
|
||||
|
||||
const queryOptions = all
|
||||
? {}
|
||||
: {
|
||||
page,
|
||||
limit,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
|
||||
const { analytics, total } = await getAllAnalytics(account_id, link_id, queryOptions);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Analytics retrieved successfully',
|
||||
success: true,
|
||||
analytics,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Analytics retrieval error:', { error, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve analytics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete analytics record
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Analytics deletion request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
let link_id, analytics_id, delete_all;
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
const body = await req.json();
|
||||
link_id = body.link_id;
|
||||
analytics_id = body.analytics_id;
|
||||
delete_all = body.delete_all;
|
||||
}
|
||||
} catch {
|
||||
logger.info('Analytics deletion request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!link_id) {
|
||||
logger.info('Analytics deletion request failed due to missing link_id', {
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing link_id parameter',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all analytics for a link
|
||||
if (delete_all) {
|
||||
const result = await removeAllAnalytics(account_id, link_id);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('All analytics deletion request succeeded', {
|
||||
account_id,
|
||||
link_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'All analytics records deleted successfully',
|
||||
success: true,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} else {
|
||||
logger.info('All analytics deletion request failed', {
|
||||
error: result.status,
|
||||
account_id,
|
||||
link_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete single analytics record
|
||||
if (!analytics_id) {
|
||||
logger.info('Analytics deletion request failed due to missing analytics_id', {
|
||||
account_id,
|
||||
link_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing analytics_id parameter for single record deletion',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await removeAnalytics(account_id, link_id, analytics_id);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Single analytics record deletion request succeeded', {
|
||||
account_id,
|
||||
link_id,
|
||||
analytics_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Analytics record deleted successfully',
|
||||
success: true,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} else {
|
||||
logger.info('Single analytics record deletion request failed', {
|
||||
error: result.status,
|
||||
account_id,
|
||||
link_id,
|
||||
analytics_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Analytics deletion error:', { error, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to delete analytics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
96
src/app/api/auth/[...nextauth]/route.ts
Normal file
96
src/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import NextAuth, { NextAuthOptions, User } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import logger from '@/lib/logger';
|
||||
import { existsUser, isUserAdmin } from '@/lib/userdb';
|
||||
import { JWT } from 'next-auth/jwt';
|
||||
import { Session } from 'next-auth';
|
||||
import { createSession, updateSessionActivity, revokeSession } from '@/lib/sessiondb';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'Account ID',
|
||||
credentials: {
|
||||
accountId: { label: 'Account ID', type: 'text', placeholder: 'Enter your Account ID' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const { accountId } = credentials as { accountId: string };
|
||||
|
||||
const exists = await existsUser(accountId);
|
||||
|
||||
if (exists) {
|
||||
const isAdmin = await isUserAdmin(accountId);
|
||||
|
||||
return {
|
||||
id: accountId,
|
||||
accountId,
|
||||
isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger }: { token: JWT; user: User; trigger?: string }) {
|
||||
if (user) {
|
||||
token.accountId = user.accountId;
|
||||
token.isAdmin = user.isAdmin;
|
||||
|
||||
const headersList = await headers();
|
||||
const userAgent = headersList.get('user-agent') || 'Unknown';
|
||||
const ip = headersList.get('x-forwarded-for') || headersList.get('x-real-ip') || 'Unknown';
|
||||
|
||||
const { sessionId } = await createSession(user.accountId, userAgent, ip);
|
||||
|
||||
token.sessionId = sessionId;
|
||||
}
|
||||
|
||||
if (trigger === 'update' && token.sessionId) {
|
||||
await updateSessionActivity(token.sessionId as string);
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: { session: Session; token: JWT }) {
|
||||
if (token) {
|
||||
session.user = {
|
||||
...session.user,
|
||||
accountId: token.accountId as string,
|
||||
isAdmin: token.isAdmin as boolean,
|
||||
sessionId: token.sessionId as string,
|
||||
};
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async signOut({ token }) {
|
||||
if (token?.sessionId) {
|
||||
try {
|
||||
await revokeSession(token.sessionId as string, token.accountId as string);
|
||||
} catch (error) {
|
||||
logger.error('Error terminating session on signOut:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: '/',
|
||||
error: '/',
|
||||
},
|
||||
session: {
|
||||
strategy: 'jwt' as const,
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days (session lifetime)
|
||||
},
|
||||
jwt: {
|
||||
maxAge: 5 * 60, // 5 min (token lifetime)
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET || '/JZ9N+lqRtvspbAfs0HK41RkthPYuUdqxb+cuimYOXw=',
|
||||
};
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
49
src/app/api/auth/check-session/route.ts
Normal file
49
src/app/api/auth/check-session/route.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { isSessionValid } from '@/lib/sessiondb';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.accountId || !session?.user?.sessionId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
message: 'No active session',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = await isSessionValid(session.user.sessionId, session.user.accountId);
|
||||
|
||||
if (!isValid) {
|
||||
logger.info('Session check failed - revoked or expired session', {
|
||||
sessionId: session.user.sessionId,
|
||||
accountId: session.user.accountId,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
message: 'Session has been revoked',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ valid: true });
|
||||
} catch (error) {
|
||||
logger.error('Error checking session:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
message: 'Error checking session',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
76
src/app/api/auth/register/route.ts
Normal file
76
src/app/api/auth/register/route.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createUser, isUserAdmin } from '@/lib/userdb';
|
||||
import logger from '@/lib/logger';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
let is_admin;
|
||||
logger.info('Registration request', { url: req.url });
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
const body = await req.json();
|
||||
is_admin = body.is_admin;
|
||||
}
|
||||
} catch {
|
||||
logger.info('Registration request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (is_admin) {
|
||||
const isAuthorized = await isUserAdmin(session?.user?.accountId);
|
||||
|
||||
if (isAuthorized) {
|
||||
const account = await createUser(is_admin);
|
||||
logger.info('Account creation request succeeded (admin)', { is_admin, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Admin registration successful',
|
||||
success: true,
|
||||
account_id: account.account_id,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} else {
|
||||
logger.info('Registration request failed due to missing rights', {
|
||||
is_admin,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized admin registration attempt',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const account = await createUser(false);
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Registration successful',
|
||||
success: true,
|
||||
account_id: account.account_id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Registration error:', { error, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Registration failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
59
src/app/api/auth/remove/route.ts
Normal file
59
src/app/api/auth/remove/route.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { removeUser, isUserAdmin } from '@/lib/userdb';
|
||||
import logger from '@/lib/logger';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
let account_id;
|
||||
logger.info('Account removal request', { url: req.url });
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
const body = await req.json();
|
||||
account_id = body.account_id;
|
||||
}
|
||||
} catch {
|
||||
logger.info('Account removal request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (session?.user?.accountId === account_id || (await isUserAdmin(session?.user?.accountId))) {
|
||||
const accountRemovalResponse = await removeUser(account_id);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: accountRemovalResponse.status,
|
||||
success: accountRemovalResponse.success,
|
||||
},
|
||||
{ status: accountRemovalResponse.success ? 200 : 500 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Account removal request failed due to missing rights', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized account removal attempt',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Account removal error:', { error, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account removal failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/app/api/auth/sessions/revoke/route.ts
Normal file
55
src/app/api/auth/sessions/revoke/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { revokeSession } from '@/lib/sessiondb';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { sessionId } = await req.json();
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Session ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await revokeSession(sessionId, session.user.accountId);
|
||||
|
||||
const isCurrentSession = sessionId === session.user.sessionId;
|
||||
if (isCurrentSession) {
|
||||
signOut({ redirect: false });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...result,
|
||||
isCurrentSession,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error revoking session:', { error });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to revoking session',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/app/api/auth/sessions/route.ts
Normal file
44
src/app/api/auth/sessions/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { getSessions } from '@/lib/sessiondb';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const sessions = await getSessions(session.user.accountId);
|
||||
|
||||
// Mark the current session
|
||||
const currentSessionId = session.user.sessionId;
|
||||
const sessionsWithCurrent = sessions.map(s => ({
|
||||
...s,
|
||||
isCurrentSession: s.id === currentSessionId,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
sessions: sessionsWithCurrent,
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting sessions:', { error });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve sessions',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
348
src/app/api/link/route.ts
Normal file
348
src/app/api/link/route.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import logger from '@/lib/logger';
|
||||
import { isValidUrl, sanitizeMongoDocument } from '@/lib/utils';
|
||||
import { getLinkById, createLink, editLink, removeLink } from '@/lib/linkdb';
|
||||
import { removeAllAnalytics } from '@/lib/analyticsdb';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
|
||||
// View link
|
||||
// Example: /api/link?shortId=SHORT_ID
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Link retrieval request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const shortId = url.searchParams.get('shortId');
|
||||
|
||||
if (!shortId) {
|
||||
logger.info('Link retrieval request failed due to missing shortId', {
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing shortId parameter',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { link, return: returnValue } = await getLinkById(account_id, shortId);
|
||||
|
||||
if (returnValue.success && link) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link retrieved successfully',
|
||||
success: true,
|
||||
link: sanitizeMongoDocument(link),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Link retrieval request failed', {
|
||||
error: returnValue.status,
|
||||
account_id,
|
||||
shortId,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: returnValue.status || 'Link not found',
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Link retrieval error:', { error, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve link',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create link
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Link creation request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
let target_url;
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
const body = await req.json();
|
||||
target_url = body.target_url;
|
||||
}
|
||||
} catch {
|
||||
logger.info('Link creation request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!target_url || !isValidUrl(target_url)) {
|
||||
logger.info('Link creation request failed due to invalid URL', { account_id, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Invalid URL. Please provide a valid URL with http:// or https://',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { shortId, return: returnValue } = await createLink(account_id, target_url);
|
||||
|
||||
if (returnValue.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link Creation succeeded',
|
||||
success: true,
|
||||
shortId,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Link creation request failed', {
|
||||
error: returnValue.status,
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: returnValue.status || 'Link creation failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Link creation error:', { error, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link creation failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit link
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Link edit request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
let shortId, target_url;
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
const body = await req.json();
|
||||
shortId = body.shortId;
|
||||
target_url = body.target_url;
|
||||
}
|
||||
} catch {
|
||||
logger.info('Link edit request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!shortId) {
|
||||
logger.info('Link edit request failed due to missing shortId', { account_id, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing shortId parameter',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!target_url || !isValidUrl(target_url)) {
|
||||
logger.info('Link edit request failed due to invalid URL', { account_id, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Invalid URL. Please provide a valid URL with http:// or https://',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const returnValue = await editLink(account_id, shortId, target_url);
|
||||
|
||||
if (returnValue.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link updated successfully',
|
||||
success: true,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Link edit request failed', {
|
||||
error: returnValue.status,
|
||||
account_id,
|
||||
shortId,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: returnValue.status || 'Link update failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Link edit error:', { error, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link update failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete link
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Link removal request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
let shortId;
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
const body = await req.json();
|
||||
shortId = body.shortId;
|
||||
}
|
||||
} catch {
|
||||
logger.info('Link removal request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!shortId) {
|
||||
logger.info('Link removal request failed due to missing shortId', {
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing shortId parameter',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await removeAllAnalytics(account_id, shortId);
|
||||
const returnValue = await removeLink(account_id, shortId);
|
||||
|
||||
if (returnValue.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link removal succeeded',
|
||||
success: true,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Link removal request failed', {
|
||||
error: returnValue.status,
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: returnValue.status || 'Link removal failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Link removal error:', { error, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link removal failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
60
src/app/api/links/route.ts
Normal file
60
src/app/api/links/route.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getLinks } from '@/lib/linkdb';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { sanitizeMongoDocument } from '@/lib/utils';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
// Get all links
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Links retrieval request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
const { links, return: returnValue } = await getLinks(account_id);
|
||||
|
||||
if (returnValue.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Links retrieved successfully',
|
||||
success: true,
|
||||
links: links.map(link => sanitizeMongoDocument(link)),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Links retrieval request failed', {
|
||||
error: returnValue.status,
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: returnValue.status || 'Failed to retrieve links',
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Links retrieval error:', { error, url: req.url });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve links',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/app/api/statistics/route.ts
Normal file
43
src/app/api/statistics/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getAllStats, updateStats } from '@/lib/statisticsdb';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
let stats = await getAllStats();
|
||||
|
||||
if (
|
||||
!stats ||
|
||||
!stats.last_updated ||
|
||||
new Date().getTime() - new Date(stats.last_updated).getTime() > 5 * 60 * 1000
|
||||
) {
|
||||
// 5min
|
||||
await updateStats();
|
||||
stats = await getAllStats();
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve statistics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Statistics retrieved successfully',
|
||||
success: true,
|
||||
stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Statistics retrieval error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'An error occurred while retrieving statistics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
165
src/app/dashboard/Dashboard.module.css
Normal file
165
src/app/dashboard/Dashboard.module.css
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
.dashboardContainer {
|
||||
max-width: 75rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.dashboardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dashboardTitle {
|
||||
font-size: 2rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.securityButton,
|
||||
.adminButton {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.securityButton {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.adminButton {
|
||||
background-color: #8b5cf6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.securityButton:hover,
|
||||
.adminButton:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.urlShortener {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.createForm {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.urlInput {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.createButton {
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.createButton:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.createButton:disabled {
|
||||
background-color: var(--accent-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.linksSection {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.noLinks {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dashboardContainer {
|
||||
max-width: 100%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.inputGroup {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboardHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.urlShortener,
|
||||
.linksSection {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dashboardContainer {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.dashboardTitle {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.securityButton,
|
||||
.adminButton {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
530
src/app/dashboard/link/[shortId]/page.tsx
Normal file
530
src/app/dashboard/link/[shortId]/page.tsx
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import Graph from '@/components/ui/Graph';
|
||||
import ConfirmModal from '@/components/ui/ConfirmModal';
|
||||
import AnalyticsTable from '@/components/ui/dashboard/link/AnalyticsTable';
|
||||
import styles from './LinkDetail.module.css';
|
||||
import { Analytics } from '@/types/analytics';
|
||||
import type { Link as LinkType } from '@/types/link';
|
||||
import type { StatItem } from '@/types/statistics';
|
||||
|
||||
export default function LinkDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const shortId = params.shortId as string;
|
||||
|
||||
const [link, setLink] = useState<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>
|
||||
);
|
||||
}
|
||||
160
src/app/dashboard/page.tsx
Normal file
160
src/app/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import LinkTable from '@/components/ui/dashboard/LinkTable';
|
||||
import styles from './Dashboard.module.css';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const [links, setLinks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/');
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.accountId) {
|
||||
fetchLinks();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const fetchLinks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/links');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch links');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setLinks(data.links);
|
||||
} else {
|
||||
showToast(data.message || 'Failed to load links', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching links:', error);
|
||||
showToast('Failed to load links', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'loading') {
|
||||
return <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);
|
||||
}
|
||||
}
|
||||
34
src/app/l/[shortId]/route.ts
Normal file
34
src/app/l/[shortId]/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getTargetUrl } from '@/lib/linkdb';
|
||||
import { getClientInfo } from '@/lib/utils';
|
||||
import { saveAnalytics } from '@/lib/analyticsdb';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ shortId: string }> }) {
|
||||
try {
|
||||
const { shortId } = await params;
|
||||
|
||||
const link = await getTargetUrl(shortId);
|
||||
|
||||
if (!link || !link.target_url) {
|
||||
return NextResponse.redirect(new URL('/not-found', req.url));
|
||||
}
|
||||
|
||||
const clientInfo = await getClientInfo(req);
|
||||
|
||||
const analyticsData = {
|
||||
link_id: shortId,
|
||||
account_id: link.account_id,
|
||||
...clientInfo,
|
||||
};
|
||||
|
||||
saveAnalytics(analyticsData).catch(err =>
|
||||
logger.error('Failed to save analytics', { error: err, shortId })
|
||||
);
|
||||
|
||||
return NextResponse.redirect(new URL(link.target_url));
|
||||
} catch (error) {
|
||||
logger.error('Link redirection error', error);
|
||||
return NextResponse.redirect(new URL('/error', req.url));
|
||||
}
|
||||
}
|
||||
48
src/app/layout.tsx
Normal file
48
src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import Providers from '@/components/Providers';
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import Header from '@/components/header';
|
||||
import Footer from '@/components/footer';
|
||||
import { ToastProvider } from '@/contexts/ToastContext';
|
||||
import Toast from '@/components/ui/Toast';
|
||||
import ResponsiveLayout from '@/components/ResponsiveLayout';
|
||||
import SessionMonitor from '@/components/SessionMonitor';
|
||||
import './globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'µLinkShortener',
|
||||
description: 'Create short links and see who accessed them!',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<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;
|
||||
}
|
||||
108
src/app/privacy/page.tsx
Normal file
108
src/app/privacy/page.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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;
|
||||
}
|
||||
112
src/app/tos/page.tsx
Normal file
112
src/app/tos/page.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
230
src/components/Header.module.css
Normal file
230
src/components/Header.module.css
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
.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);
|
||||
}
|
||||
7
src/components/Providers.tsx
Normal file
7
src/components/Providers.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
'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;
|
||||
}
|
||||
17
src/components/footer.tsx
Normal file
17
src/components/footer.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
189
src/components/header.tsx
Normal file
189
src/components/header.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
94
src/components/ui/ConfirmModal.module.css
Normal file
94
src/components/ui/ConfirmModal.module.css
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
86
src/components/ui/ConfirmModal.tsx
Normal file
86
src/components/ui/ConfirmModal.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
'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;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type === 'line' && (
|
||||
<Line
|
||||
data={getLineData()}
|
||||
options={getOptions()}
|
||||
ref={ref => {
|
||||
if (ref) chartRef.current = ref;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type === 'doughnut' && (
|
||||
<Doughnut
|
||||
data={getDoughnutData()}
|
||||
options={getOptions()}
|
||||
ref={ref => {
|
||||
if (ref) chartRef.current = ref;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
63
src/components/ui/Toast.module.css
Normal file
63
src/components/ui/Toast.module.css
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
.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%;
|
||||
}
|
||||
}
|
||||
205
src/components/ui/admin/AdminLinkTable.tsx
Normal file
205
src/components/ui/admin/AdminLinkTable.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
'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%;
|
||||
}
|
||||
}
|
||||
195
src/components/ui/dashboard/LinkTable.tsx
Normal file
195
src/components/ui/dashboard/LinkTable.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
159
src/components/ui/dashboard/SessionManager.module.css
Normal file
159
src/components/ui/dashboard/SessionManager.module.css
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
188
src/components/ui/dashboard/SessionManager.tsx
Normal file
188
src/components/ui/dashboard/SessionManager.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
'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;
|
||||
}
|
||||
156
src/lib/analyticsdb.ts
Normal file
156
src/lib/analyticsdb.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
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' };
|
||||
}
|
||||
}
|
||||
155
src/lib/linkdb.ts
Normal file
155
src/lib/linkdb.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
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' };
|
||||
}
|
||||
}
|
||||
16
src/lib/logger.ts
Normal file
16
src/lib/logger.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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;
|
||||
40
src/lib/mongodb.ts
Normal file
40
src/lib/mongodb.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Db, MongoClient, ObjectId } from 'mongodb';
|
||||
|
||||
let client: MongoClient;
|
||||
let db: Db;
|
||||
|
||||
export const Collection = {
|
||||
links_collection: 'links',
|
||||
analytics_collection: 'analytics',
|
||||
statistics_collection: 'statistics',
|
||||
user_collection: 'users',
|
||||
sessions_collection: 'sessions',
|
||||
ip_addresses_collection: 'ip_addresses',
|
||||
};
|
||||
|
||||
export async function getMongo(): Promise<{ client: MongoClient; db: Db }> {
|
||||
if (client && db) {
|
||||
return { client, db };
|
||||
}
|
||||
|
||||
if (!process.env.MONGO_URI) {
|
||||
throw new Error('Please add your MongoDB URI to .env');
|
||||
}
|
||||
if (!process.env.MONGO_DB_NAME) {
|
||||
throw new Error('Please add your MongoDB DB name to .env');
|
||||
}
|
||||
|
||||
client = new MongoClient(process.env.MONGO_URI);
|
||||
await client.connect();
|
||||
db = client.db(process.env.MONGO_DB_NAME);
|
||||
|
||||
return { client, db };
|
||||
}
|
||||
|
||||
export function safeObjectId(id: string): ObjectId | null {
|
||||
try {
|
||||
return new ObjectId(id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
138
src/lib/sessiondb.ts
Normal file
138
src/lib/sessiondb.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { getMongo, Collection } from './mongodb';
|
||||
import type { SessionInfo } from '@/types/session';
|
||||
import type { DetailedReturn } from '@/types/global';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function createSession(
|
||||
accountId: string,
|
||||
userAgent: string,
|
||||
ipAddress: string
|
||||
): Promise<{ sessionId: string; return: DetailedReturn }> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<SessionInfo>(Collection.sessions_collection);
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(now.getDate() + 30);
|
||||
|
||||
const sessionId = uuidv4();
|
||||
const session: SessionInfo = {
|
||||
id: sessionId,
|
||||
accountId,
|
||||
userAgent,
|
||||
ipAddress,
|
||||
lastActive: now,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
await collection.insertOne(session);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
return: { success: true, status: 'Session created' },
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error creating session:', error);
|
||||
return {
|
||||
sessionId: '',
|
||||
return: { success: false, status: 'Failed to create session' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function isSessionValid(sessionId: string, accountId: string): Promise<boolean> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection(Collection.sessions_collection);
|
||||
|
||||
const session = await collection.findOne({
|
||||
id: sessionId,
|
||||
accountId: accountId,
|
||||
revoked: { $ne: true },
|
||||
});
|
||||
|
||||
return !!session;
|
||||
} catch (error) {
|
||||
logger.error('Error checking session validity:', { error, sessionId, accountId });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessions(accountId: string): Promise<SessionInfo[]> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<SessionInfo>(Collection.sessions_collection);
|
||||
|
||||
const sessions = await collection
|
||||
.find({
|
||||
accountId,
|
||||
expiresAt: { $gt: new Date() },
|
||||
})
|
||||
.sort({ lastActive: -1 })
|
||||
.toArray();
|
||||
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
logger.error('Error getting sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeSession(sessionId: string, accountId: string): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<SessionInfo>(Collection.sessions_collection);
|
||||
|
||||
const result = await collection.deleteOne({
|
||||
id: sessionId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: result.deletedCount > 0,
|
||||
status: result.deletedCount > 0 ? 'Session revoked' : 'Session not found',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error revoking session:', error);
|
||||
return {
|
||||
success: false,
|
||||
status: 'Failed to revoke session',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAllSessionsByAccountId(accountId: string): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<SessionInfo>(Collection.sessions_collection);
|
||||
|
||||
const result = await collection.deleteMany({
|
||||
accountId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: result.deletedCount > 0,
|
||||
status: result.deletedCount > 0 ? 'Sessions terminated' : 'No Sessions found',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error terminating session:', error);
|
||||
return {
|
||||
success: false,
|
||||
status: 'Failed to terminate session',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionActivity(sessionId: string): Promise<void> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<SessionInfo>(Collection.sessions_collection);
|
||||
|
||||
await collection.updateOne({ id: sessionId }, { $set: { lastActive: new Date() } });
|
||||
} catch (error) {
|
||||
logger.error('Error updating session activity:', error);
|
||||
}
|
||||
}
|
||||
86
src/lib/statisticsdb.ts
Normal file
86
src/lib/statisticsdb.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { getMongo, Collection } from './mongodb';
|
||||
import { formatOSStrings } from './utils';
|
||||
import type { Stats, StatItem } from '@/types/statistics';
|
||||
import type { Analytics } from '@/types/analytics';
|
||||
import type { Link } from '@/types/link';
|
||||
import { DetailedReturn } from '@/types/global';
|
||||
|
||||
export async function getAllStats(): Promise<Stats | null> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const statsCollection = db.collection<Stats>(Collection.statistics_collection);
|
||||
|
||||
// We have only one item in this collection!!!
|
||||
// Is it dumb? most probably yes, but it's better than searching on every request
|
||||
const stats = await statsCollection.findOne();
|
||||
return stats ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateStats(): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const statisticsCollection = db.collection<Stats>(Collection.statistics_collection);
|
||||
|
||||
// Get and update the data
|
||||
const analyticsCollection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
const total_links = await db.collection<Link>(Collection.links_collection).countDocuments();
|
||||
const total_clicks = await analyticsCollection.countDocuments();
|
||||
const ipv6_count = await analyticsCollection.countDocuments({ ip_version: 'IPv6' });
|
||||
|
||||
const ip_versions: StatItem[] = [
|
||||
{ id: 'IPv4', count: total_clicks - ipv6_count },
|
||||
{ id: 'IPv6', count: ipv6_count },
|
||||
];
|
||||
|
||||
const os_stats_raw = await analyticsCollection
|
||||
.aggregate<StatItem>([
|
||||
{ $group: { _id: '$platform', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $project: { id: '$_id', count: 1, _id: 0 } },
|
||||
])
|
||||
.toArray();
|
||||
|
||||
const os_stats = os_stats_raw.map(item => ({
|
||||
id: formatOSStrings(item.id),
|
||||
count: item.count,
|
||||
}));
|
||||
|
||||
const country_stats = await analyticsCollection
|
||||
.aggregate<StatItem>([
|
||||
{ $group: { _id: '$country', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $project: { id: '$_id', count: 1, _id: 0 } },
|
||||
])
|
||||
.toArray();
|
||||
|
||||
const isp_stats = await analyticsCollection
|
||||
.aggregate<StatItem>([
|
||||
{ $group: { _id: '$ip_data.isp', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $project: { id: '$_id', count: 1, _id: 0 } },
|
||||
])
|
||||
.toArray();
|
||||
|
||||
const newStats: Stats = {
|
||||
total_links,
|
||||
total_clicks,
|
||||
chart_data: {
|
||||
ip_versions,
|
||||
os_stats,
|
||||
country_stats,
|
||||
isp_stats,
|
||||
},
|
||||
last_updated: new Date(),
|
||||
};
|
||||
|
||||
const result = await statisticsCollection.replaceOne({}, newStats, { upsert: true });
|
||||
const success = result.modifiedCount > 0;
|
||||
|
||||
return { success, status: success ? 'Stats successfully updated' : 'Failed to update stats' };
|
||||
} catch {
|
||||
return { success: false, status: 'An exception occurred' };
|
||||
}
|
||||
}
|
||||
169
src/lib/userdb.ts
Normal file
169
src/lib/userdb.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { getMongo, Collection } from './mongodb';
|
||||
import { generateAccountID } from './utils';
|
||||
import { removeAllAnalyticsFromUser } from './analyticsdb';
|
||||
import { removeAllLinksFromUser } from './linkdb';
|
||||
import { removeAllSessionsByAccountId } from './sessiondb';
|
||||
import type { User } from '@/types/user';
|
||||
import type { DetailedReturn } from '@/types/global';
|
||||
import type { Filter } from 'mongodb';
|
||||
|
||||
export interface UserQueryOptions {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function isUserAdmin(account_id: string): Promise<boolean> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
||||
const user = await collection.findOne({ account_id: account_id });
|
||||
|
||||
return user?.is_admin ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeUserAdmin(account_id: string, admin: boolean): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
||||
const result = await collection.updateOne(
|
||||
{ account_id: account_id },
|
||||
{ $set: { is_admin: admin } }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
return { success: false, status: 'User not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.modifiedCount > 0,
|
||||
status:
|
||||
result.modifiedCount > 0
|
||||
? `User is now ${admin ? 'an admin' : 'no longer an admin'}`
|
||||
: 'No changes were made',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error toggling admin status:', error);
|
||||
return { success: false, status: 'An exception occurred' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function existsUser(account_id: string): Promise<boolean> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
||||
const user = await collection.findOne({ account_id: account_id });
|
||||
|
||||
return user !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(
|
||||
is_admin: boolean
|
||||
): Promise<{ account_id: string; return: DetailedReturn }> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
||||
let account_id;
|
||||
let duplicate;
|
||||
do {
|
||||
account_id = generateAccountID();
|
||||
const existing = await collection.findOne({ account_id: account_id });
|
||||
duplicate = existing !== null;
|
||||
} while (duplicate);
|
||||
|
||||
const newUser: User = {
|
||||
account_id: account_id,
|
||||
is_admin: is_admin,
|
||||
created_at: new Date(),
|
||||
};
|
||||
|
||||
const result = await collection.insertOne(newUser);
|
||||
if (!result.acknowledged)
|
||||
return { account_id: '', return: { success: false, status: 'An error occured' } };
|
||||
|
||||
return { account_id, return: { success: true, status: 'User was successfully created' } };
|
||||
} catch {
|
||||
return { account_id: '', return: { success: false, status: 'An exception occured' } };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUser(account_id: string): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
||||
const result = await collection.deleteOne({ account_id: account_id });
|
||||
const removeAnalyticsResult = await removeAllAnalyticsFromUser(account_id);
|
||||
const removeLinksResult = await removeAllLinksFromUser(account_id);
|
||||
await removeAllSessionsByAccountId(account_id);
|
||||
const success =
|
||||
result.deletedCount > 0 && removeAnalyticsResult.success && removeLinksResult.success;
|
||||
|
||||
return { success: success, status: success ? 'User successfully deleted' : 'An error occured' };
|
||||
} catch {
|
||||
return { success: false, status: 'An exception occured' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserById(account_id: string): Promise<User | null> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
||||
const user = await collection.findOne({ account_id });
|
||||
return user;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listUsers(
|
||||
query_options: UserQueryOptions = {}
|
||||
): Promise<{ users: User[] | null; total: number; return: DetailedReturn }> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
||||
const { startDate, endDate, search, page = 1, limit = 50 } = query_options;
|
||||
const query: Filter<User> = {};
|
||||
|
||||
if (startDate || endDate) {
|
||||
query.created_at = {};
|
||||
if (startDate) query.created_at.$gte = startDate;
|
||||
if (endDate) query.created_at.$lte = endDate;
|
||||
}
|
||||
|
||||
if (search && search.trim() !== '') {
|
||||
query.account_id = { $regex: new RegExp(search, 'i') };
|
||||
}
|
||||
|
||||
const cursor = collection
|
||||
.find(query)
|
||||
.sort({ created_at: -1 }) // Most recent first
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit);
|
||||
|
||||
const users = await cursor.toArray();
|
||||
const total = await collection.countDocuments(query);
|
||||
|
||||
return {
|
||||
users: users,
|
||||
total: total,
|
||||
return: { success: true, status: 'Users successfully fetched' },
|
||||
};
|
||||
} catch {
|
||||
return { users: null, total: 0, return: { success: false, status: 'An exception occured' } };
|
||||
}
|
||||
}
|
||||
127
src/lib/utils.ts
Normal file
127
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { NextRequest } from 'next/server';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { getIPData } from './analyticsdb';
|
||||
|
||||
// For accounts
|
||||
const letterBytes = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const linkLength = 8;
|
||||
const accountLength = 16;
|
||||
const authTokenLength = 128;
|
||||
|
||||
function randomString(length: number, charset: string): string {
|
||||
let result = '';
|
||||
const charsetLength = charset.length;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * charsetLength);
|
||||
result += charset[randomIndex];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function generateLinkID(): string {
|
||||
return randomString(linkLength, letterBytes);
|
||||
}
|
||||
|
||||
export function generateAccountID(): string {
|
||||
return randomString(accountLength, '0123456789');
|
||||
}
|
||||
|
||||
export function generateAuthToken(): string {
|
||||
return randomString(authTokenLength, letterBytes);
|
||||
}
|
||||
|
||||
// For Links
|
||||
export function isValidUrl(urlStr: string): boolean {
|
||||
if (urlStr.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(urlStr);
|
||||
return parsedUrl.protocol !== '' && parsedUrl.hostname !== '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For Clients
|
||||
const defaultValue = 'Unknown';
|
||||
function valueOrDefault(value: string | null): string {
|
||||
return !value || value?.trim() === '' ? defaultValue : value;
|
||||
}
|
||||
|
||||
export async function getClientInfo(req: NextRequest) {
|
||||
const headers = req.headers;
|
||||
|
||||
// Get the IP address
|
||||
let ip_address = headers.get('cf-connecting-ip') || headers.get('x-real-ip') || '';
|
||||
if (!ip_address) {
|
||||
const forwardedFor = headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
ip_address = forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!ip_address) {
|
||||
ip_address = defaultValue;
|
||||
}
|
||||
|
||||
const ipVersion = ip_address.includes(':') ? 'IPv6' : 'IPv4';
|
||||
|
||||
// User-Agent Parsing
|
||||
const uaString = headers.get('user-agent') ?? '';
|
||||
const ua = new UAParser(uaString);
|
||||
const browser = ua.getBrowser().name ?? defaultValue;
|
||||
const version = ua.getBrowser().version ?? defaultValue;
|
||||
const os = ua.getOS().name ?? defaultValue;
|
||||
|
||||
// Platform
|
||||
const platform = headers.get('sec-ch-ua-platform') ?? os;
|
||||
|
||||
// Language
|
||||
let language = headers.get('accept-language');
|
||||
if (language?.includes(',')) {
|
||||
language = language.split(',')[0];
|
||||
}
|
||||
language = valueOrDefault(language);
|
||||
|
||||
return {
|
||||
ip_address,
|
||||
user_agent: uaString,
|
||||
platform,
|
||||
browser,
|
||||
version,
|
||||
language,
|
||||
referrer: valueOrDefault(headers.get('referer')),
|
||||
timestamp: new Date(),
|
||||
remote_port: valueOrDefault(headers.get('x-forwarded-port')),
|
||||
accept: valueOrDefault(headers.get('accept')),
|
||||
accept_language: valueOrDefault(headers.get('accept-language')),
|
||||
accept_encoding: valueOrDefault(headers.get('accept-encoding')),
|
||||
country: valueOrDefault(headers.get('cf-ipcountry')),
|
||||
ip_data: await getIPData(ip_address),
|
||||
ip_version: ipVersion,
|
||||
};
|
||||
}
|
||||
|
||||
// For stats
|
||||
export function formatOSStrings(os_string: string): string {
|
||||
os_string = os_string.trim();
|
||||
os_string = os_string.replaceAll('"', ''); // Windows usually reports ""Windows"""
|
||||
os_string = os_string.replaceAll('CPU ', ''); // iOS usually reports "CPU ....."
|
||||
os_string = os_string.replaceAll(' like Mac OS X', ''); // iOS usually reports at its end " like Mac OS X"
|
||||
|
||||
return os_string;
|
||||
}
|
||||
|
||||
// For MongoDB
|
||||
export function sanitizeMongoDocument<T>(doc: T & { _id?: unknown }): T {
|
||||
if (!doc) return doc;
|
||||
|
||||
const sanitized = { ...doc };
|
||||
delete sanitized._id;
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
56
src/proxy.ts
Normal file
56
src/proxy.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
const response = NextResponse.next();
|
||||
|
||||
try {
|
||||
if (
|
||||
path === '/dashboard' ||
|
||||
path === '/admin' ||
|
||||
path.startsWith('/api/link/') ||
|
||||
path.startsWith('/dashboard/') ||
|
||||
path.startsWith('/admin/')
|
||||
) {
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.NEXTAUTH_SECRET || 'fallback-secret-for-testing',
|
||||
});
|
||||
|
||||
// Not authenticated
|
||||
if (!token) {
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
|
||||
// Check token expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (token.exp && (token.exp as number) < now) {
|
||||
return NextResponse.redirect(new URL('/api/auth/signout?callbackUrl=/', request.url));
|
||||
}
|
||||
|
||||
// Check admin access
|
||||
if ((path === '/admin' || path.startsWith('/admin/')) && !token.isAdmin) {
|
||||
return NextResponse.redirect(new URL('/dashboard', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Middleware error:', error);
|
||||
// On error, still allow the request to proceed
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/dashboard',
|
||||
'/dashboard/:path*',
|
||||
'/admin',
|
||||
'/admin/:path*',
|
||||
'/api/link/:path*',
|
||||
'/api/auth/sessions/:path*',
|
||||
],
|
||||
};
|
||||
30
src/types/analytics.d.ts
vendored
Normal file
30
src/types/analytics.d.ts
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { ObjectId } from 'mongodb';
|
||||
|
||||
export interface Analytics {
|
||||
_id?: ObjectId;
|
||||
link_id: string;
|
||||
account_id: string;
|
||||
ip_address: string;
|
||||
user_agent: string;
|
||||
platform: string;
|
||||
browser: string;
|
||||
version: string;
|
||||
language: string;
|
||||
referrer: string;
|
||||
timestamp: Date;
|
||||
remote_port: string;
|
||||
accept: string;
|
||||
accept_language: string;
|
||||
accept_encoding: string;
|
||||
country: string;
|
||||
ip_data: IPAddress;
|
||||
ip_version: string;
|
||||
}
|
||||
|
||||
export interface IPAddress {
|
||||
ip_address: string;
|
||||
ip_version: string;
|
||||
isp: string;
|
||||
country: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
22
src/types/auth.d.ts
vendored
Normal file
22
src/types/auth.d.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import 'next-auth';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
accountId: string;
|
||||
isAdmin: boolean;
|
||||
} & DefaultSession['user'];
|
||||
}
|
||||
|
||||
interface User {
|
||||
accountId: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
accountId: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
}
|
||||
4
src/types/global.d.ts
vendored
Normal file
4
src/types/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface DetailedReturn {
|
||||
success: boolean;
|
||||
status: string;
|
||||
}
|
||||
7
src/types/link.d.ts
vendored
Normal file
7
src/types/link.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export interface Link {
|
||||
short_id: string;
|
||||
target_url: string;
|
||||
account_id: string;
|
||||
created_at: Date;
|
||||
last_modified: Date;
|
||||
}
|
||||
10
src/types/session.d.ts
vendored
Normal file
10
src/types/session.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export interface SessionInfo {
|
||||
id: string;
|
||||
accountId: string;
|
||||
userAgent: string;
|
||||
lastActive: Date;
|
||||
ipAddress: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
isCurrentSession?: boolean;
|
||||
}
|
||||
18
src/types/statistics.d.ts
vendored
Normal file
18
src/types/statistics.d.ts
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export interface Stats {
|
||||
total_links: number;
|
||||
total_clicks: number;
|
||||
chart_data: ChartData;
|
||||
last_updated: Date;
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
ip_versions: StatItem[];
|
||||
os_stats: StatItem[];
|
||||
country_stats: StatItem[];
|
||||
isp_stats: StatItem[];
|
||||
}
|
||||
|
||||
export interface StatItem {
|
||||
id: string;
|
||||
count: number;
|
||||
}
|
||||
5
src/types/user.d.ts
vendored
Normal file
5
src/types/user.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface User {
|
||||
account_id: string;
|
||||
is_admin: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
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