diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..02e0ec1
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+# URL de base du site (production)
+NEXT_PUBLIC_BASE_URL=https://reducelink.arthurp.fr
+
+# Base de données SQLite
+DATABASE_URL="file:./dev.db"
diff --git a/.gitignore b/.gitignore
index 5ef6a52..e0e08ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,14 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
+
+# local databases
+dev.db
+prisma/dev.db
+
+# local editor deployment config
+.vscode/sftp.json
# vercel
.vercel
@@ -39,3 +47,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+/src/generated/prisma
diff --git a/README.md b/README.md
index e215bc4..1ae9741 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,125 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+# ReduceLink đ
-## Getting Started
+Un service de raccourcissement de liens gratuit, simple et transparent.
-First, run the development server:
+- Site officiel : https://reducelink.arthurp.fr
+- DépÎt GitHub : https://github.com/arthur-pbty/reducelink
+
+
+
+
+
+
+## ⚠Fonctionnalités
+
+- **Raccourcissement de liens** : Transformez vos URLs longues en liens courts
+- **Alias personnalisé** : Choisissez votre propre alias ou laissez-en générer un automatiquement
+- **QR Code** : Chaque lien génÚre un QR Code téléchargeable
+- **Statistiques** : Suivez le nombre de clics en temps réel
+- **100% gratuit** : Aucune inscription, aucune limitation
+- **Transparence** : Tous les liens sont publics
+
+## đ DĂ©marrage rapide
+
+### Prérequis
+
+- Node.js 18+
+- npm ou yarn
+
+### Installation
```bash
+# Cloner le projet
+git clone https://github.com/arthur-pbty/reducelink.git
+cd reducelink
+
+# Installer les dépendances
+npm install
+
+# Configurer l'environnement
+cp .env.example .env
+
+# Initialiser la base de données
+npx prisma migrate dev
+
+# Lancer le serveur de développement
npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
-bun dev
```
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+Ouvrez [http://localhost:3000](http://localhost:3000) dans votre navigateur.
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+## đ Structure du projet
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+```
+reducelink/
+âââ prisma/
+â âââ schema.prisma # SchĂ©ma de la base de donnĂ©es
+â âââ migrations/ # Migrations SQL
+âââ src/
+â âââ app/ # Pages Next.js (App Router)
+â â âââ page.tsx # Page d'accueil
+â â âââ liens/ # Liste des liens
+â â âââ stats/ # Statistiques
+â â âââ a-propos/ # Ă propos
+â â âââ conditions/ # CGU
+â â âââ [shortCode]/ # Redirection dynamique
+â âââ components/ # Composants React
+â âââ lib/ # Utilitaires et actions
+âââ public/ # Assets statiques
+âââ package.json
+```
-## Learn More
+## đ ïž Technologies
-To learn more about Next.js, take a look at the following resources:
+- **Framework** : Next.js 16 avec App Router
+- **Langage** : TypeScript
+- **Base de données** : SQLite avec Prisma
+- **Styles** : Tailwind CSS
+- **QR Code** : qrcode.react
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+## đ Pages
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+| Route | Description |
+|-------|-------------|
+| / | Page d'accueil avec formulaire |
+| /liens | Liste de tous les liens |
+| /stats | Statistiques globales |
+| /a-propos | Informations sur le service |
+| /conditions | Conditions d'utilisation |
+| /{shortCode} | Redirection vers l'URL originale |
-## Deploy on Vercel
+## đ§ Configuration
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+Variables d'environnement (.env) :
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+```env
+# Base de données SQLite
+DATABASE_URL="file:./dev.db"
+
+# URL de base du site
+NEXT_PUBLIC_BASE_URL=http://localhost:3000
+```
+
+## đŠ Scripts
+
+```bash
+npm run dev # Serveur de développement
+npm run build # Build de production
+npm run start # Serveur de production
+npm run lint # Linting ESLint
+```
+
+## đïž Base de donnĂ©es
+
+Commandes Prisma utiles :
+
+```bash
+npx prisma migrate dev # Créer une migration
+npx prisma migrate reset # Réinitialiser la DB
+npx prisma studio # Interface visuelle
+npx prisma generate # Générer le client
+```
+
+---
+
+Fait avec â€ïž pour simplifier vos URLs.
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 0000000..d1adda0
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,19 @@
+services:
+ reducelink:
+ image: node:20-alpine
+ container_name: reducelink
+ working_dir: /app
+ volumes:
+ - .:/app
+ - node_modules:/app/node_modules
+ ports:
+ - "3000:3000"
+ environment:
+ - NODE_ENV=development
+ - DATABASE_URL=file:./prisma/dev.db
+ - NEXT_PUBLIC_BASE_URL=http://localhost:3000
+ command: sh -c "npm install && npx prisma generate && npx prisma migrate dev --name init && npm run dev"
+ restart: unless-stopped
+
+volumes:
+ node_modules:
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..2b35e5a
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,18 @@
+services:
+ reducelink:
+ image: node:20-alpine
+ container_name: reducelink
+ working_dir: /app
+ volumes:
+ - .:/app
+ - node_modules:/app/node_modules
+ ports:
+ - "3006:3000"
+ environment:
+ - DATABASE_URL=file:./dev.db
+ - NEXT_PUBLIC_BASE_URL=https://reducelink.arthurp.fr
+ command: sh -c "apk add --no-cache openssl libc6-compat && npm install && npx prisma generate && npx prisma migrate deploy && npm run build && npm run start"
+ restart: unless-stopped
+
+volumes:
+ node_modules:
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..46dcaf2 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,55 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ compress: true,
+ poweredByHeader: false,
+ async headers() {
+ return [
+ {
+ source: "/(.*)",
+ headers: [
+ {
+ key: "X-Content-Type-Options",
+ value: "nosniff",
+ },
+ {
+ key: "X-Frame-Options",
+ value: "DENY",
+ },
+ {
+ key: "X-XSS-Protection",
+ value: "1; mode=block",
+ },
+ {
+ key: "Referrer-Policy",
+ value: "strict-origin-when-cross-origin",
+ },
+ {
+ key: "Permissions-Policy",
+ value: "camera=(), microphone=(), geolocation=()",
+ },
+ ],
+ },
+ {
+ source: "/manifest.json",
+ headers: [
+ {
+ key: "Cache-Control",
+ value: "public, max-age=604800, immutable",
+ },
+ ],
+ },
+ ];
+ },
+ async redirects() {
+ return [
+ {
+ source: "/index",
+ destination: "/",
+ permanent: true,
+ },
+ ];
+ },
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index 96d9542..edf4458 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,17 +8,24 @@
"name": "reducelink",
"version": "0.1.0",
"dependencies": {
- "next": "16.1.6",
+ "@prisma/client": "5.22.0",
+ "nanoid": "^3.3.11",
+ "next": "^16.2.1",
+ "prisma": "5.22.0",
+ "qrcode": "^1.5.4",
+ "qrcode.react": "^4.2.0",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "react-qrcode-logo": "^4.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
- "eslint-config-next": "16.1.6",
+ "eslint-config-next": "^16.2.1",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -1036,15 +1043,15 @@
}
},
"node_modules/@next/env": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
- "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz",
+ "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
- "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.1.tgz",
+ "integrity": "sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1052,9 +1059,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
- "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz",
+ "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==",
"cpu": [
"arm64"
],
@@ -1068,9 +1075,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
- "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz",
+ "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==",
"cpu": [
"x64"
],
@@ -1084,9 +1091,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
- "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz",
+ "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==",
"cpu": [
"arm64"
],
@@ -1100,9 +1107,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
- "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz",
+ "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==",
"cpu": [
"arm64"
],
@@ -1116,9 +1123,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
- "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz",
+ "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==",
"cpu": [
"x64"
],
@@ -1132,9 +1139,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
- "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz",
+ "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==",
"cpu": [
"x64"
],
@@ -1148,9 +1155,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
- "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz",
+ "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==",
"cpu": [
"arm64"
],
@@ -1164,9 +1171,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
- "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz",
+ "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==",
"cpu": [
"x64"
],
@@ -1227,6 +1234,69 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@prisma/client": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
+ "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.13"
+ },
+ "peerDependencies": {
+ "prisma": "*"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
+ "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
+ "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/fetch-engine": "5.22.0",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
+ "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
+ "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
+ "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1556,6 +1626,16 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+ "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
@@ -2157,11 +2237,19 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -2538,6 +2626,15 @@
"node": ">=6"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
@@ -2581,11 +2678,21 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -2598,7 +2705,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
@@ -2716,6 +2822,15 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2769,6 +2884,12 @@
"node": ">=8"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3087,13 +3208,13 @@
}
},
"node_modules/eslint-config-next": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
- "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.1.tgz",
+ "integrity": "sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@next/eslint-plugin-next": "16.1.6",
+ "@next/eslint-plugin-next": "16.2.1",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -3591,6 +3712,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -3652,6 +3787,15 @@
"node": ">=6.9.0"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -4132,6 +4276,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -4955,14 +5108,14 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
- "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz",
+ "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==",
"license": "MIT",
"dependencies": {
- "@next/env": "16.1.6",
+ "@next/env": "16.2.1",
"@swc/helpers": "0.5.15",
- "baseline-browser-mapping": "^2.8.3",
+ "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -4974,15 +5127,15 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "16.1.6",
- "@next/swc-darwin-x64": "16.1.6",
- "@next/swc-linux-arm64-gnu": "16.1.6",
- "@next/swc-linux-arm64-musl": "16.1.6",
- "@next/swc-linux-x64-gnu": "16.1.6",
- "@next/swc-linux-x64-musl": "16.1.6",
- "@next/swc-win32-arm64-msvc": "16.1.6",
- "@next/swc-win32-x64-msvc": "16.1.6",
- "sharp": "^0.34.4"
+ "@next/swc-darwin-arm64": "16.2.1",
+ "@next/swc-darwin-x64": "16.2.1",
+ "@next/swc-linux-arm64-gnu": "16.2.1",
+ "@next/swc-linux-arm64-musl": "16.2.1",
+ "@next/swc-linux-x64-gnu": "16.2.1",
+ "@next/swc-linux-x64-musl": "16.2.1",
+ "@next/swc-win32-arm64-msvc": "16.2.1",
+ "@next/swc-win32-x64-msvc": "16.2.1",
+ "sharp": "^0.34.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -5233,6 +5386,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5250,7 +5412,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5280,9 +5441,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5292,6 +5453,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5341,6 +5511,26 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prisma": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
+ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@prisma/engines": "5.22.0"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=16.13"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ }
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5363,6 +5553,38 @@
"node": ">=6"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qrcode-generator": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
+ "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
+ "license": "MIT"
+ },
+ "node_modules/qrcode.react": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
+ "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5414,6 +5636,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-qrcode-logo": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/react-qrcode-logo/-/react-qrcode-logo-4.0.0.tgz",
+ "integrity": "sha512-TcDdsJQe7P0OY7uA7Do4Z0DfIIjjqx81RbBGQY+90T2Ba42pUCx/cSI2UTwPPoH9WwE0StLb8A98mFgKIAI4JQ==",
+ "license": "MIT",
+ "dependencies": {
+ "qrcode-generator": "^2.0.4"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0",
+ "react-dom": ">=18.0.0"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5458,6 +5693,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5605,6 +5855,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -5841,6 +6097,26 @@
"node": ">= 0.4"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -5954,6 +6230,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -6469,6 +6757,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@@ -6501,6 +6795,26 @@
"node": ">=0.10.0"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -6508,6 +6822,93 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/yargs/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/yargs/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index 1aefcf8..f1b117b 100644
--- a/package.json
+++ b/package.json
@@ -9,17 +9,24 @@
"lint": "eslint"
},
"dependencies": {
- "next": "16.1.6",
+ "@prisma/client": "5.22.0",
+ "nanoid": "^3.3.11",
+ "next": "^16.2.1",
+ "prisma": "5.22.0",
+ "qrcode": "^1.5.4",
+ "qrcode.react": "^4.2.0",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "react-qrcode-logo": "^4.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
- "eslint-config-next": "16.1.6",
+ "eslint-config-next": "^16.2.1",
"tailwindcss": "^4",
"typescript": "^5"
}
diff --git a/prisma/migrations/20260201133121_init/migration.sql b/prisma/migrations/20260201133121_init/migration.sql
new file mode 100644
index 0000000..28a8d76
--- /dev/null
+++ b/prisma/migrations/20260201133121_init/migration.sql
@@ -0,0 +1,22 @@
+-- CreateTable
+CREATE TABLE "Link" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "originalUrl" TEXT NOT NULL,
+ "shortCode" TEXT NOT NULL,
+ "clickCount" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "title" TEXT,
+ "favicon" TEXT
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Link_shortCode_key" ON "Link"("shortCode");
+
+-- CreateIndex
+CREATE INDEX "Link_shortCode_idx" ON "Link"("shortCode");
+
+-- CreateIndex
+CREATE INDEX "Link_createdAt_idx" ON "Link"("createdAt");
+
+-- CreateIndex
+CREATE INDEX "Link_clickCount_idx" ON "Link"("clickCount");
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..e5e5c47
--- /dev/null
+++ b/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "sqlite"
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 0000000..cd99a43
--- /dev/null
+++ b/prisma/schema.prisma
@@ -0,0 +1,26 @@
+// Schema Prisma pour ReduceLink
+// Base de données SQLite pour stocker les liens raccourcis
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+// ModĂšle pour les liens raccourcis
+model Link {
+ id Int @id @default(autoincrement())
+ originalUrl String // URL originale complĂšte
+ shortCode String @unique // Alias/code court unique
+ clickCount Int @default(0) // Compteur de clics
+ createdAt DateTime @default(now()) // Date de création
+ title String? // Titre de la page (optionnel)
+ favicon String? // URL du favicon (optionnel)
+
+ @@index([shortCode])
+ @@index([createdAt])
+ @@index([clickCount])
+}
diff --git a/public/file.svg b/public/file.svg
deleted file mode 100644
index 004145c..0000000
--- a/public/file.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/globe.svg b/public/globe.svg
deleted file mode 100644
index 567f17b..0000000
--- a/public/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..dd3aefc
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,22 @@
+{
+ "name": "ReduceLink - Raccourcisseur de liens gratuit",
+ "short_name": "ReduceLink",
+ "description": "Raccourcissez vos liens gratuitement. Simple, rapide et sans inscription. QR Code et statistiques inclus.",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#f9fafb",
+ "theme_color": "#2563eb",
+ "lang": "fr",
+ "icons": [
+ {
+ "src": "/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}
diff --git a/public/next.svg b/public/next.svg
deleted file mode 100644
index 5174b28..0000000
--- a/public/next.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/vercel.svg b/public/vercel.svg
deleted file mode 100644
index 7705396..0000000
--- a/public/vercel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
deleted file mode 100644
index b2b2a44..0000000
--- a/public/window.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/app/[shortCode]/page.tsx b/src/app/[shortCode]/page.tsx
new file mode 100644
index 0000000..bf945ff
--- /dev/null
+++ b/src/app/[shortCode]/page.tsx
@@ -0,0 +1,32 @@
+import { redirect, notFound } from 'next/navigation'
+import { getLinkAndIncrementClick } from '@/lib/queries'
+
+interface RedirectPageProps {
+ params: Promise<{
+ shortCode: string
+ }>
+}
+
+// Page de redirection dynamique
+// Cette route capture tous les chemins /{shortCode}
+export default async function RedirectPage({ params }: RedirectPageProps) {
+ const { shortCode } = await params
+
+ console.log('Tentative de redirection pour:', shortCode)
+
+ // Récupérer le lien et incrémenter le compteur
+ const link = await getLinkAndIncrementClick(shortCode)
+
+ console.log('Lien trouvé:', link)
+
+ // Si le lien n'existe pas, afficher 404
+ if (!link) {
+ notFound()
+ }
+
+ // Rediriger vers l'URL originale
+ redirect(link.originalUrl)
+}
+
+// Désactiver le cache pour cette page (compteur de clics)
+export const dynamic = 'force-dynamic'
diff --git a/src/app/a-propos/page.tsx b/src/app/a-propos/page.tsx
new file mode 100644
index 0000000..9f664ce
--- /dev/null
+++ b/src/app/a-propos/page.tsx
@@ -0,0 +1,263 @@
+import { Metadata } from 'next'
+import Link from 'next/link'
+
+export const metadata: Metadata = {
+ title: 'Ă propos de ReduceLink - Service de raccourcissement de liens gratuit',
+ description:
+ 'Découvrez ReduceLink, le raccourcisseur de liens gratuit, sans inscription et respectueux de la vie privée. Notre mission : simplifier le partage de liens avec QR Code et statistiques.',
+ alternates: {
+ canonical: '/a-propos',
+ },
+ openGraph: {
+ title: 'Ă propos de ReduceLink',
+ description: 'Découvrez notre service gratuit de raccourcissement de liens. Simple, transparent et respectueux de la vie privée.',
+ url: '/a-propos',
+ locale: 'fr_FR',
+ siteName: 'ReduceLink',
+ type: 'website',
+ images: [{
+ url: '/opengraph-image',
+ width: 1200,
+ height: 630,
+ alt: 'ReduceLink - Raccourcisseur de liens gratuit',
+ }],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: 'Ă propos de ReduceLink',
+ description: 'Découvrez notre service gratuit de raccourcissement de liens.',
+ images: ['/opengraph-image'],
+ },
+}
+
+// Page Ă propos
+export default function AboutPage() {
+ const breadcrumbJsonLd = {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: [
+ {
+ "@type": "ListItem",
+ position: 1,
+ name: "Accueil",
+ item: "https://reducelink.arthurp.fr",
+ },
+ {
+ "@type": "ListItem",
+ position: 2,
+ name: "Ă propos",
+ item: "https://reducelink.arthurp.fr/a-propos",
+ },
+ ],
+ }
+
+ return (
+ <>
+
+
+ {/* En-tĂȘte */}
+
+
+ Ă propos de ReduceLink
+
+
+ Un service simple et transparent de raccourcissement de liens
+
+
+
+ {/* Contenu principal */}
+
+ {/* Mission */}
+
+
+
+
+
+
+
+
+ Notre mission
+
+
+ ReduceLink est né d'une idée simple : proposer un service de raccourcissement
+ de liens gratuit , sans inscription et respectueux
+ de la vie privĂ©e . Dans un monde oĂč les donnĂ©es personnelles sont devenues
+ une monnaie d'échange, nous croyons qu'il est possible de fournir un service
+ utile sans compromettre votre vie privée.
+
+
+
+
+ {/* Pas de comptes */}
+
+
+
+
+
+
+
+
+ Pas de compte utilisateur
+
+
+ Contrairement Ă d'autres services, ReduceLink ne requiert aucune inscription .
+ Pas d'email à fournir, pas de mot de passe à créer, pas de profil à gérer.
+ Vous collez votre lien, vous obtenez un lien court. C'est tout.
+
+
+
+
+
+
+ Aucune inscription nécessaire
+
+
+
+
+
+ Utilisation immédiate
+
+
+
+
+
+ Aucune donnée personnelle collectée
+
+
+
+
+
+ {/* Transparence */}
+
+
+
+
+
+
+
+
+
+ Transparence totale
+
+
+ Tous les liens créés sur ReduceLink sont publics . N'importe qui peut
+ voir la liste complĂšte des liens, leurs statistiques de clics et les URLs de destination.
+ Cette transparence est un choix délibéré.
+
+
+
Pourquoi la transparence ?
+
+ La transparence permet de lutter contre les abus. Si un lien mĂšne vers un contenu
+ malveillant, tout le monde peut le voir. Cela encourage une utilisation responsable
+ du service et protĂšge les utilisateurs finaux.
+
+
+
+
+
+ {/* Vie privée */}
+
+
+
+
+
+
+
+
+ Respect de la vie privée
+
+
+ Nous ne collectons aucune donnée personnelle . Pas de cookies de tracking,
+ pas d'adresse IP enregistrée, pas d'empreinte digitale du navigateur.
+ Les seules informations stockées sont :
+
+
+
+
+ L'URL originale
+
+
+
+ L'alias court
+
+
+
+ Le nombre de clics (anonyme)
+
+
+
+ La date de création
+
+
+
+
+
+ {/* Comment ça marche */}
+
+
+
+
+
+
+
+
+ Comment ça marche
+
+
+
+
+ 1
+
+
Collez votre lien
+
+ Entrez l'URL longue que vous souhaitez raccourcir
+
+
+
+
+ 2
+
+
Choisissez un alias
+
+ Personnalisé ou généré automatiquement
+
+
+
+
+ 3
+
+
Partagez !
+
+ Copiez le lien ou téléchargez le QR Code
+
+
+
+
+
+
+ {/* CTA */}
+
+
+
PrĂȘt Ă raccourcir vos liens ?
+
+ C'est gratuit, instantané et sans inscription.
+
+
+ Commencer maintenant
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/app/apple-icon.tsx b/src/app/apple-icon.tsx
new file mode 100644
index 0000000..df58c68
--- /dev/null
+++ b/src/app/apple-icon.tsx
@@ -0,0 +1,33 @@
+import { ImageResponse } from 'next/og'
+
+export const size = {
+ width: 180,
+ height: 180,
+}
+export const contentType = 'image/png'
+
+export default function AppleIcon() {
+ return new ImageResponse(
+ (
+
+ R
+
+ ),
+ {
+ ...size,
+ }
+ )
+}
diff --git a/src/app/conditions/page.tsx b/src/app/conditions/page.tsx
new file mode 100644
index 0000000..51ee1b0
--- /dev/null
+++ b/src/app/conditions/page.tsx
@@ -0,0 +1,249 @@
+import { Metadata } from 'next'
+import Link from 'next/link'
+
+export const metadata: Metadata = {
+ title: "Conditions d'utilisation de ReduceLink",
+ description:
+ "Conditions d'utilisation de ReduceLink. RĂšgles simples et transparentes pour l'utilisation de notre service gratuit de raccourcissement de liens.",
+ alternates: {
+ canonical: '/conditions',
+ },
+ openGraph: {
+ title: "Conditions d'utilisation de ReduceLink",
+ description: "RĂšgles simples et transparentes pour l'utilisation de notre service gratuit de raccourcissement de liens.",
+ url: '/conditions',
+ locale: 'fr_FR',
+ siteName: 'ReduceLink',
+ type: 'website',
+ images: [{
+ url: '/opengraph-image',
+ width: 1200,
+ height: 630,
+ alt: 'ReduceLink - Raccourcisseur de liens gratuit',
+ }],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: "Conditions d'utilisation de ReduceLink",
+ description: "RĂšgles simples et transparentes pour l'utilisation de notre service.",
+ images: ['/opengraph-image'],
+ },
+ robots: {
+ index: true,
+ follow: true,
+ },
+}
+
+// Page Conditions d'utilisation
+export default function TermsPage() {
+ return (
+ <>
+
+
+ {/* En-tĂȘte */}
+
+
+ Conditions d'utilisation
+
+
+ DerniÚre mise à jour : Février 2026
+
+
+
+ {/* Contenu */}
+
+
+ {/* Introduction */}
+
+ 1. Acceptation des conditions
+
+ En utilisant ReduceLink, vous acceptez les présentes conditions d'utilisation.
+ Ces conditions sont simples et visent Ă garantir un usage respectueux du service
+ pour tous les utilisateurs.
+
+
+
+ {/* Description du service */}
+
+ 2. Description du service
+
+ ReduceLink est un service gratuit de raccourcissement de liens. Il permet de :
+
+
+ Transformer une URL longue en lien court
+ Choisir un alias personnalisé ou généré automatiquement
+ Obtenir un QR Code pour chaque lien
+ Consulter les statistiques de clics
+
+
+
+ {/* Liens publics */}
+
+ 3. CaractĂšre public des liens
+
+
+ â ïž Tous les liens créés sur ReduceLink sont publics.
+
+
+
+ Cela signifie que n'importe qui peut :
+
+
+ Voir la liste de tous les liens créés
+ Consulter l'URL de destination de chaque lien
+ Voir le nombre de clics sur chaque lien
+
+
+ Ne créez pas de liens vers des contenus sensibles ou privés.
+
+
+
+ {/* Pas de suppression */}
+
+ 4. Permanence des liens
+
+
+ âčïž Les liens créés ne peuvent pas ĂȘtre supprimĂ©s.
+
+
+
+ Une fois un lien créé, il est permanent. Cette rÚgle garantit :
+
+
+ La fiabilité des liens partagés
+ La cohérence des statistiques
+ La simplicité du service (pas de gestion de compte)
+
+
+ Réfléchissez avant de créer un lien. En cas d'abus manifeste, nous nous réservons
+ le droit de désactiver un lien.
+
+
+
+ {/* Contenus interdits */}
+
+ 5. Contenus interdits
+
+ Il est interdit de créer des liens vers :
+
+
+ Contenus illégaux
+ Malwares, virus ou logiciels malveillants
+ Phishing ou arnaques
+ Contenus haineux ou discriminatoires
+ Contenus portant atteinte aux droits d'autrui
+
+
+ Nous nous réservons le droit de désactiver tout lien violant ces rÚgles.
+
+
+
+ {/* Responsabilité */}
+
+ 6. Limitation de responsabilité
+
+
+ ReduceLink n'est pas responsable du contenu des liens.
+
+
+ Nous fournissons uniquement un service de redirection. Le contenu vers lequel
+ pointent les liens est sous la responsabilité exclusive des créateurs de ces liens
+ et des propriétaires des sites de destination.
+
+
+
+
+ {/* Disponibilité */}
+
+ 7. Disponibilité du service
+
+ Nous faisons de notre mieux pour maintenir le service disponible 24h/24, 7j/7.
+ Cependant, nous ne pouvons garantir une disponibilité absolue. Des interruptions
+ peuvent survenir pour maintenance ou pour des raisons techniques.
+
+
+
+ {/* Gratuité */}
+
+ 8. Gratuité
+
+ ReduceLink est et restera 100% gratuit . Aucun plan payant,
+ aucune fonctionnalité premium, aucune publicité intrusive. Le service est
+ financé de maniÚre indépendante.
+
+
+
+ {/* Modifications */}
+
+ 9. Modifications des conditions
+
+ Nous pouvons modifier ces conditions Ă tout moment. Les modifications entrent
+ en vigueur dĂšs leur publication sur cette page. La date de derniĂšre mise Ă jour
+ est indiquée en haut de ce document.
+
+
+
+ {/* Contact */}
+
+ 10. Contact
+
+ Pour toute question concernant ces conditions d'utilisation ou le service
+ en général, consultez notre page{' '}
+
+ Ă propos
+ .
+
+
+
+
+
+ {/* Résumé */}
+
+
En résumé
+
+
+
+
+
+ Service gratuit et sans inscription
+
+
+
+
+
+ Tous les liens sont publics
+
+
+
+
+
+ Pas de suppression possible
+
+
+
+
+
+ Contenus illégaux interdits
+
+
+
+
+
+ Nous ne sommes pas responsables du contenu des liens
+
+
+
+
+ >
+ )
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index a2dc41e..67ff862 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,26 +1,76 @@
@import "tailwindcss";
:root {
- --background: #ffffff;
- --foreground: #171717;
+ --background: #f9fafb;
+ --foreground: #111827;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
+ --font-sans: var(--font-inter);
}
+/* ThĂšme clair uniquement - pas de mode sombre */
body {
background: var(--background);
color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ font-family: var(--font-sans), system-ui, sans-serif;
}
+
+/* Animations */
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slide-in-from-bottom {
+ from {
+ transform: translateY(10px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.animate-in {
+ animation: fade-in 0.3s ease-out, slide-in-from-bottom 0.3s ease-out;
+}
+
+/* Focus visible pour l'accessibilité */
+*:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: 2px;
+}
+
+/* Scrollbar personnalisée */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #f1f5f9;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #cbd5e1;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #94a3b8;
+}
+
+/* Selection */
+::selection {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
diff --git a/src/app/icon.tsx b/src/app/icon.tsx
new file mode 100644
index 0000000..27bf717
--- /dev/null
+++ b/src/app/icon.tsx
@@ -0,0 +1,33 @@
+import { ImageResponse } from 'next/og'
+
+export const size = {
+ width: 32,
+ height: 32,
+}
+export const contentType = 'image/x-icon'
+
+export default function Icon() {
+ return new ImageResponse(
+ (
+
+ R
+
+ ),
+ {
+ ...size,
+ }
+ )
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f7fa87e..f94ba75 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,20 +1,154 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import type { Metadata, Viewport } from "next";
+import { Inter } from "next/font/google";
import "./globals.css";
+import Header from "@/components/Header";
+import Footer from "@/components/Footer";
-const geistSans = Geist({
- variable: "--font-geist-sans",
+const inter = Inter({
+ variable: "--font-inter",
subsets: ["latin"],
+ display: "swap",
});
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
+const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://reducelink.arthurp.fr";
+
+export const viewport: Viewport = {
+ themeColor: "#2563eb",
+ width: "device-width",
+ initialScale: 1,
+};
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ metadataBase: new URL(baseUrl),
+ title: {
+ default: "ReduceLink - Raccourcisseur de liens gratuit",
+ template: "%s | ReduceLink",
+ },
+ description:
+ "Raccourcissez vos liens gratuitement avec ReduceLink. Simple, rapide et sans inscription. Créez des liens courts personnalisés avec QR Code, statistiques de clics et alias mémorables.",
+ keywords: [
+ "raccourcisseur de liens",
+ "raccourcir url",
+ "shortener",
+ "url shortener",
+ "liens courts",
+ "réduire lien",
+ "QR code",
+ "gratuit",
+ "sans inscription",
+ "raccourcisseur gratuit",
+ "lien court gratuit",
+ "générateur QR code",
+ "statistiques liens",
+ "short link",
+ "reducelink",
+ ],
+ authors: [{ name: "ReduceLink", url: baseUrl }],
+ creator: "ReduceLink",
+ publisher: "ReduceLink",
+ applicationName: "ReduceLink",
+ generator: "Next.js",
+ referrer: "origin-when-cross-origin",
+ formatDetection: {
+ email: false,
+ address: false,
+ telephone: false,
+ },
+ alternates: {
+ canonical: "/",
+ },
+ openGraph: {
+ type: "website",
+ locale: "fr_FR",
+ url: baseUrl,
+ siteName: "ReduceLink",
+ title: "ReduceLink - Raccourcisseur de liens gratuit",
+ description:
+ "Raccourcissez vos liens gratuitement. Simple, rapide, sans inscription. QR Code et statistiques inclus.",
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "ReduceLink - Raccourcisseur de liens gratuit",
+ description:
+ "Raccourcissez vos liens gratuitement. Simple, rapide, sans inscription. QR Code et statistiques inclus.",
+ creator: "@reducelink",
+ },
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ "max-video-preview": -1,
+ "max-image-preview": "large",
+ "max-snippet": -1,
+ },
+ },
+ category: "technology",
+ classification: "URL Shortener",
+ other: {
+ "google-site-verification": "",
+ "msvalidate.01": "",
+ },
+};
+
+// JSON-LD structured data for the website
+const jsonLd = {
+ "@context": "https://schema.org",
+ "@graph": [
+ {
+ "@type": "WebSite",
+ "@id": `${baseUrl}/#website`,
+ url: baseUrl,
+ name: "ReduceLink",
+ description:
+ "Raccourcissez vos liens gratuitement avec ReduceLink. Simple, rapide et sans inscription.",
+ inLanguage: "fr-FR",
+ potentialAction: {
+ "@type": "SearchAction",
+ target: {
+ "@type": "EntryPoint",
+ urlTemplate: `${baseUrl}/liens?search={search_term_string}`,
+ },
+ "query-input": "required name=search_term_string",
+ },
+ },
+ {
+ "@type": "Organization",
+ "@id": `${baseUrl}/#organization`,
+ name: "ReduceLink",
+ url: baseUrl,
+ logo: {
+ "@type": "ImageObject",
+ url: `${baseUrl}/icon-512.png`,
+ },
+ sameAs: [],
+ },
+ {
+ "@type": "WebApplication",
+ "@id": `${baseUrl}/#webapp`,
+ name: "ReduceLink",
+ url: baseUrl,
+ applicationCategory: "UtilitiesApplication",
+ operatingSystem: "All",
+ offers: {
+ "@type": "Offer",
+ price: "0",
+ priceCurrency: "EUR",
+ },
+ description:
+ "Service gratuit de raccourcissement de liens avec QR Code et statistiques de clics.",
+ inLanguage: "fr-FR",
+ featureList: [
+ "Raccourcissement de liens",
+ "Génération de QR Code",
+ "Statistiques de clics",
+ "Alias personnalisés",
+ "Sans inscription",
+ "100% gratuit",
+ ],
+ },
+ ],
};
export default function RootLayout({
@@ -23,11 +157,19 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
-
- {children}
+
+
+
+
+
+
+
+
+ {children}
+
);
diff --git a/src/app/liens/page.tsx b/src/app/liens/page.tsx
new file mode 100644
index 0000000..09fa4c5
--- /dev/null
+++ b/src/app/liens/page.tsx
@@ -0,0 +1,133 @@
+import { Metadata } from 'next'
+import { Suspense } from 'react'
+import LinkCard from '@/components/LinkCard'
+import LinkFilters from '@/components/LinkFilters'
+import Pagination from '@/components/Pagination'
+import { getLinks } from '@/lib/actions'
+import type { SortOption } from '@/lib/types'
+import { formatNumber } from '@/lib/utils'
+
+export const metadata: Metadata = {
+ title: 'Tous les liens raccourcis - Explorer les URLs courtes',
+ description:
+ 'Explorez tous les liens raccourcis créés sur ReduceLink. Liste publique et transparente avec recherche, tri par popularité et statistiques de clics en temps réel.',
+ alternates: {
+ canonical: '/liens',
+ },
+ openGraph: {
+ title: 'Tous les liens raccourcis sur ReduceLink',
+ description: 'Explorez les liens raccourcis créés sur ReduceLink avec recherche, tri et statistiques.',
+ url: '/liens',
+ locale: 'fr_FR',
+ siteName: 'ReduceLink',
+ type: 'website',
+ images: [{
+ url: '/opengraph-image',
+ width: 1200,
+ height: 630,
+ alt: 'ReduceLink - Raccourcisseur de liens gratuit',
+ }],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: 'Tous les liens raccourcis sur ReduceLink',
+ description: 'Explorez les liens raccourcis sur ReduceLink.',
+ images: ['/opengraph-image'],
+ },
+}
+
+interface LinksPageProps {
+ searchParams: Promise<{
+ page?: string
+ sort?: SortOption
+ search?: string
+ }>
+}
+
+// Page liste des liens
+export default async function LinksPage({ searchParams }: LinksPageProps) {
+ const params = await searchParams
+ const page = Number(params.page) || 1
+ const sort = (params.sort as SortOption) || 'recent'
+ const search = params.search || ''
+
+ const { links, total, totalPages } = await getLinks(page, 20, sort, search)
+
+ return (
+ <>
+
+
+ {/* En-tĂȘte */}
+
+
+ Tous les liens
+
+
+ {formatNumber(total)} lien{total !== 1 ? 's' : ''} raccourci{total !== 1 ? 's' : ''}
+
+
+
+ {/* Filtres */}
+
}>
+
+
+
+ {/* Liste des liens */}
+ {links.length > 0 ? (
+ <>
+
+ {links.map((link) => (
+
+ ))}
+
+
+ {/* Pagination */}
+
+ >
+ ) : (
+
+
+
+
+
+ Aucun lien trouvé
+
+
+ {search
+ ? `Aucun résultat pour "${search}". Essayez avec d'autres termes.`
+ : "Aucun lien n'a encore été créé. Soyez le premier !"}
+
+
+ )}
+
+ >
+ )
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..45f3208
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,108 @@
+import { Metadata } from 'next'
+import Link from 'next/link'
+
+export const metadata: Metadata = {
+ title: 'Page non trouvée',
+ description: 'La page que vous recherchez n\'existe pas.',
+}
+
+// Page 404 personnalisée
+export default function NotFoundPage() {
+ return (
+
+ {/* Illustration */}
+
+
+ {/* Code d'erreur */}
+
404
+
+ {/* Message */}
+
+ Lien introuvable
+
+
+ Le lien que vous recherchez n'existe pas ou a été désactivé.
+ Vérifiez l'orthographe de l'URL ou créez un nouveau lien.
+
+
+ {/* Actions */}
+
+
+
+
+
+ Créer un lien
+
+
+
+
+
+ Rechercher un lien
+
+
+
+ {/* Aide */}
+
+
Que faire ?
+
+
+
+
+
+ Vérifiez que l'URL est correctement écrite
+
+
+
+
+
+ Utilisez la barre de recherche pour trouver le lien
+
+
+
+
+
+ Si le lien a été partagé, demandez à la personne de vérifier
+
+
+
+
+
+ Créez un nouveau lien si nécessaire
+
+
+
+
+ )
+}
diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx
new file mode 100644
index 0000000..f816843
--- /dev/null
+++ b/src/app/opengraph-image.tsx
@@ -0,0 +1,110 @@
+import { ImageResponse } from 'next/og'
+
+export const runtime = 'edge'
+
+export const alt = 'ReduceLink - Raccourcisseur de liens gratuit'
+export const size = {
+ width: 1200,
+ height: 630,
+}
+export const contentType = 'image/png'
+
+export default async function Image() {
+ return new ImageResponse(
+ (
+
+ {/* Logo */}
+
+
+ {/* Title */}
+
+ Reduce
+ Link
+
+
+ {/* Subtitle */}
+
+ Raccourcisseur de liens gratuit
+
+
+ {/* Features */}
+
+ â Gratuit
+ â Sans inscription
+ â QR Code
+ â Statistiques
+
+
+ {/* URL */}
+
+ reducelink.arthurp.fr
+
+
+ ),
+ {
+ ...size,
+ }
+ )
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 295f8fd..3185461 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,65 +1,292 @@
-import Image from "next/image";
+import { Metadata } from 'next'
+import LinkForm from '@/components/LinkForm'
+import StatsCards from '@/components/StatsCards'
+import LinkCard from '@/components/LinkCard'
+import { getGlobalStats, getRecentLinks } from '@/lib/actions'
+import Link from 'next/link'
-export default function Home() {
- return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
- );
+export const metadata: Metadata = {
+ title: 'ReduceLink - Raccourcisseur de liens gratuit | URL Shortener français',
+ description:
+ 'Raccourcissez vos liens gratuitement avec ReduceLink. Créez des liens courts personnalisés ou aléatoires, avec QR Code et statistiques de clics. Simple, rapide et sans inscription. Le meilleur raccourcisseur d\'URL en français.',
+ alternates: {
+ canonical: '/',
+ },
+ openGraph: {
+ title: 'ReduceLink - Raccourcisseur de liens gratuit',
+ description: 'Créez des liens courts personnalisés avec QR Code et statistiques. Gratuit et sans inscription.',
+ url: '/',
+ locale: 'fr_FR',
+ siteName: 'ReduceLink',
+ },
+}
+
+// Page d'accueil
+export default async function HomePage() {
+ const [stats, recentLinks] = await Promise.all([
+ getGlobalStats(),
+ getRecentLinks(5),
+ ])
+
+ const faqJsonLd = {
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: [
+ {
+ "@type": "Question",
+ name: "Comment raccourcir un lien avec ReduceLink ?",
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: "Collez votre URL longue dans le champ prĂ©vu, choisissez Ă©ventuellement un alias personnalisĂ©, puis cliquez sur 'Raccourcir le lien'. Votre lien court est prĂȘt instantanĂ©ment avec un QR Code.",
+ },
+ },
+ {
+ "@type": "Question",
+ name: "Est-ce que ReduceLink est gratuit ?",
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: "Oui, ReduceLink est 100% gratuit, sans limitation et sans inscription requise. Aucun plan payant n'existe.",
+ },
+ },
+ {
+ "@type": "Question",
+ name: "Puis-je personnaliser mes liens courts ?",
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: "Oui, vous pouvez choisir un alias personnalisé pour créer des liens mémorables et professionnels, ou laisser ReduceLink générer un code aléatoire.",
+ },
+ },
+ {
+ "@type": "Question",
+ name: "Les liens raccourcis expirent-ils ?",
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: "Non, les liens créés sur ReduceLink sont permanents et ne peuvent pas ĂȘtre supprimĂ©s. Ils resteront actifs indĂ©finiment.",
+ },
+ },
+ {
+ "@type": "Question",
+ name: "ReduceLink propose-t-il des statistiques ?",
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: "Oui, chaque lien dispose de statistiques de clics publiques et transparentes. Vous pouvez suivre le nombre de redirections en temps réel.",
+ },
+ },
+ ],
+ }
+
+ const breadcrumbJsonLd = {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: [
+ {
+ "@type": "ListItem",
+ position: 1,
+ name: "Accueil",
+ item: "https://reducelink.arthurp.fr",
+ },
+ ],
+ }
+
+ return (
+ <>
+
+
+
+ {/* Hero Section */}
+
+
+ Raccourcissez vos liens
+ en un instant
+
+
+ Transformez vos URLs longues en liens courts et mémorables.
+ Gratuit, sans inscription, avec QR Code et statistiques.
+
+
+
+ {/* Formulaire de création */}
+
+
+ {/* Statistiques globales */}
+
+
+ ReduceLink en chiffres
+
+
+
+
+ {/* Fonctionnalités */}
+
+
+ Pourquoi choisir ReduceLink ?
+
+
+
+
+
+ }
+ title="Rapide et simple"
+ description="Collez votre lien, cliquez, c'est fait. Pas de compte à créer, pas de configuration."
+ />
+
+
+
+ }
+ title="QR Code inclus"
+ description="Chaque lien génÚre automatiquement un QR Code que vous pouvez copier ou télécharger."
+ />
+
+
+
+ }
+ title="Statistiques"
+ description="Suivez le nombre de clics sur vos liens. Toutes les statistiques sont publiques et transparentes."
+ />
+
+
+
+ }
+ title="Alias personnalisé"
+ description="Choisissez votre propre alias pour des liens mémorables et professionnels."
+ />
+
+
+
+ }
+ title="Respectueux de la vie privée"
+ description="Aucune donnée personnelle collectée. Pas de cookies de tracking. Juste des liens."
+ />
+
+
+
+ }
+ title="100% gratuit"
+ description="Aucun plan payant, aucune limitation. ReduceLink est et restera gratuit."
+ />
+
+
+
+ {/* Liens récents */}
+ {recentLinks.length > 0 && (
+
+
+
Liens récents
+
+ Voir tous les liens
+
+
+
+
+
+
+ {recentLinks.map((link) => (
+
+ ))}
+
+
+ )}
+
+ {/* FAQ Section - visible pour le SEO */}
+
+
+ Questions fréquentes
+
+
+
+
+ Comment raccourcir un lien avec ReduceLink ?
+
+
+
+ Collez votre URL longue dans le champ prĂ©vu, choisissez Ă©ventuellement un alias personnalisĂ©, puis cliquez sur "Raccourcir le lien". Votre lien court est prĂȘt instantanĂ©ment avec un QR Code.
+
+
+
+
+ Est-ce que ReduceLink est gratuit ?
+
+
+
+ Oui, ReduceLink est 100% gratuit, sans limitation et sans inscription requise. Aucun plan payant n'existe.
+
+
+
+
+ Puis-je personnaliser mes liens courts ?
+
+
+
+ Oui, vous pouvez choisir un alias personnalisé pour créer des liens mémorables et professionnels, ou laisser ReduceLink générer un code aléatoire.
+
+
+
+
+ Les liens raccourcis expirent-ils ?
+
+
+
+ Non, les liens créés sur ReduceLink sont permanents et ne peuvent pas ĂȘtre supprimĂ©s. Ils resteront actifs indĂ©finiment.
+
+
+
+
+ ReduceLink propose-t-il des statistiques ?
+
+
+
+ Oui, chaque lien dispose de statistiques de clics publiques et transparentes. Vous pouvez suivre le nombre de redirections en temps réel.
+
+
+
+
+
+ >
+ )
+}
+
+// Composant carte fonctionnalité
+function FeatureCard({
+ icon,
+ title,
+ description,
+}: {
+ icon: React.ReactNode
+ title: string
+ description: string
+}) {
+ return (
+
+
+ {icon}
+
+
{title}
+
{description}
+
+ )
}
diff --git a/src/app/robots.ts b/src/app/robots.ts
new file mode 100644
index 0000000..321d77f
--- /dev/null
+++ b/src/app/robots.ts
@@ -0,0 +1,23 @@
+import { MetadataRoute } from 'next'
+
+// Configuration du robots.txt pour le SEO
+export default function robots(): MetadataRoute.Robots {
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://reducelink.arthurp.fr'
+
+ return {
+ rules: [
+ {
+ userAgent: '*',
+ allow: '/',
+ disallow: ['/api/', '/_next/'],
+ },
+ {
+ userAgent: 'Googlebot',
+ allow: '/',
+ disallow: ['/api/'],
+ },
+ ],
+ sitemap: `${baseUrl}/sitemap.xml`,
+ host: baseUrl,
+ }
+}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
new file mode 100644
index 0000000..27428a9
--- /dev/null
+++ b/src/app/sitemap.ts
@@ -0,0 +1,39 @@
+import { MetadataRoute } from 'next'
+
+// Génération du sitemap pour le SEO
+export default function sitemap(): MetadataRoute.Sitemap {
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://reducelink.arthurp.fr'
+
+ return [
+ {
+ url: baseUrl,
+ lastModified: new Date(),
+ changeFrequency: 'daily',
+ priority: 1,
+ },
+ {
+ url: `${baseUrl}/liens`,
+ lastModified: new Date(),
+ changeFrequency: 'hourly',
+ priority: 0.8,
+ },
+ {
+ url: `${baseUrl}/stats`,
+ lastModified: new Date(),
+ changeFrequency: 'hourly',
+ priority: 0.8,
+ },
+ {
+ url: `${baseUrl}/a-propos`,
+ lastModified: new Date(),
+ changeFrequency: 'monthly',
+ priority: 0.5,
+ },
+ {
+ url: `${baseUrl}/conditions`,
+ lastModified: new Date(),
+ changeFrequency: 'monthly',
+ priority: 0.3,
+ },
+ ]
+}
diff --git a/src/app/stats/page.tsx b/src/app/stats/page.tsx
new file mode 100644
index 0000000..1ebffdb
--- /dev/null
+++ b/src/app/stats/page.tsx
@@ -0,0 +1,193 @@
+import { Metadata } from 'next'
+import Link from 'next/link'
+import StatsCards from '@/components/StatsCards'
+import LinkCard from '@/components/LinkCard'
+import { getGlobalStats, getPopularLinks, getRecentLinks } from '@/lib/actions'
+
+export const metadata: Metadata = {
+ title: 'Statistiques de ReduceLink - Liens et clics en temps réel',
+ description:
+ 'Découvrez les statistiques de ReduceLink en temps réel : nombre de liens créés, total de redirections, liens les plus populaires et tendances du jour.',
+ alternates: {
+ canonical: '/stats',
+ },
+ openGraph: {
+ title: 'Statistiques de ReduceLink en temps réel',
+ description: 'Nombre de liens créés, total de redirections et liens les plus populaires.',
+ url: '/stats',
+ locale: 'fr_FR',
+ siteName: 'ReduceLink',
+ type: 'website',
+ images: [{
+ url: '/opengraph-image',
+ width: 1200,
+ height: 630,
+ alt: 'ReduceLink - Raccourcisseur de liens gratuit',
+ }],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: 'Statistiques de ReduceLink en temps réel',
+ description: 'Nombre de liens créés, total de redirections et liens les plus populaires.',
+ images: ['/opengraph-image'],
+ },
+}
+
+// Page statistiques
+export default async function StatsPage() {
+ const [stats, popularLinks, recentLinks] = await Promise.all([
+ getGlobalStats(),
+ getPopularLinks(10),
+ getRecentLinks(10),
+ ])
+
+ return (
+ <>
+
+
+ {/* En-tĂȘte */}
+
+
+ Statistiques
+
+
+ Les chiffres de ReduceLink en temps réel
+
+
+
+ {/* Cartes statistiques */}
+
+
+ {/* Grille des classements */}
+
+ {/* Liens les plus populaires */}
+
+
+
+
+
+
+ Liens les plus populaires
+
+
+ Voir tout
+
+
+
+ {popularLinks.length > 0 ? (
+ popularLinks.map((link, index) => (
+
+
+ {index + 1}
+
+
+
+
+
+ ))
+ ) : (
+
+ Aucun lien n'a encore reçu de clics
+
+ )}
+
+
+
+ {/* Liens récents */}
+
+
+
+
+
+
+ Derniers liens créés
+
+
+ Voir tout
+
+
+
+ {recentLinks.length > 0 ? (
+ recentLinks.map((link) => (
+
+ ))
+ ) : (
+
+ Aucun lien n'a encore été créé
+
+ )}
+
+
+
+
+ {/* Informations supplémentaires */}
+
+
+ Ă propos de ces statistiques
+
+
+
+
+
+
+
+
Données en temps réel
+
Les statistiques sont mises à jour instantanément à chaque clic.
+
+
+
+
+
+
+
+
100% transparent
+
Toutes les données sont publiques et accessibles à tous.
+
+
+
+
+
+
+
+
Aucun tracking
+
Nous comptons uniquement les clics, pas les utilisateurs.
+
+
+
+
+
+
+
+
Liens permanents
+
Les liens créés ne sont jamais supprimés.
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/app/twitter-image.tsx b/src/app/twitter-image.tsx
new file mode 100644
index 0000000..581b4b0
--- /dev/null
+++ b/src/app/twitter-image.tsx
@@ -0,0 +1,101 @@
+import { ImageResponse } from 'next/og'
+
+export const runtime = 'edge'
+
+export const alt = 'ReduceLink - Raccourcisseur de liens gratuit'
+export const size = {
+ width: 1200,
+ height: 630,
+}
+export const contentType = 'image/png'
+
+export default async function Image() {
+ return new ImageResponse(
+ (
+
+
+
+ Reduce
+ Link
+
+
+ Raccourcisseur de liens gratuit
+
+
+ â Gratuit
+ â Sans inscription
+ â QR Code
+ â Statistiques
+
+
+ reducelink.arthurp.fr
+
+
+ ),
+ {
+ ...size,
+ }
+ )
+}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
new file mode 100644
index 0000000..9d3609a
--- /dev/null
+++ b/src/components/Footer.tsx
@@ -0,0 +1,91 @@
+import Link from 'next/link'
+
+// Composant Footer
+export default function Footer() {
+ const currentYear = new Date().getFullYear()
+
+ return (
+
+
+
+ {/* Ă propos */}
+
+
+
+
+
+ ReduceLink
+
+
+ Service gratuit de raccourcissement de liens. Simple, rapide et sans inscription.
+
+
+
+ {/* Liens rapides */}
+
+
Liens rapides
+
+
+
+ Tous les liens
+
+
+
+
+ Statistiques
+
+
+
+
+ Ă propos
+
+
+
+
+ Conditions d'utilisation
+
+
+
+
+
+ {/* Contact */}
+
+
Information
+
+ â Gratuit et sans inscription
+ â Liens publics et permanents
+ â Statistiques transparentes
+ â Respectueux de la vie privĂ©e
+
+
+
+
+ {/* Copyright */}
+
+
+ © {currentYear} ReduceLink. Tous droits réservés.
+ âą
+
+ Conditions
+
+ âą
+
+ Ă propos
+
+
+
+
+
+ )
+}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
new file mode 100644
index 0000000..cdb5be8
--- /dev/null
+++ b/src/components/Header.tsx
@@ -0,0 +1,113 @@
+'use client'
+
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+
+// Composant Header avec navigation
+export default function Header() {
+ const pathname = usePathname()
+
+ const navLinks = [
+ { href: '/', label: 'Accueil' },
+ { href: '/liens', label: 'Liens' },
+ { href: '/stats', label: 'Statistiques' },
+ { href: '/a-propos', label: 'Ă propos' },
+ ]
+
+ return (
+
+
+
+ {/* Logo */}
+
+
+
+
+
+ ReduceLink
+
+
+
+ {/* Navigation */}
+
+ {navLinks.map((link) => (
+
+ {link.label}
+
+ ))}
+
+
+ {/* Menu mobile */}
+
+
+
+
+ )
+}
+
+function MobileMenu({ navLinks, pathname }: { navLinks: { href: string; label: string }[]; pathname: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+ Menu
+
+
+
+ {navLinks.map((link) => (
+
+ {link.label}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/components/LinkCard.tsx b/src/components/LinkCard.tsx
new file mode 100644
index 0000000..d6b07e3
--- /dev/null
+++ b/src/components/LinkCard.tsx
@@ -0,0 +1,113 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import type { Link as LinkType } from '@/lib/types'
+import { extractDomain, formatNumber, formatRelativeDate, getShortUrl } from '@/lib/utils'
+
+interface LinkCardProps {
+ link: LinkType
+ showFullUrl?: boolean
+}
+
+// Carte d'affichage d'un lien
+export default function LinkCard({ link, showFullUrl = false }: LinkCardProps) {
+ const [copied, setCopied] = useState(false)
+ const shortUrl = getShortUrl(link.shortCode)
+ const domain = extractDomain(link.originalUrl)
+
+ const copyLink = async () => {
+ try {
+ await navigator.clipboard.writeText(shortUrl)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch (error) {
+ console.error('Erreur lors de la copie:', error)
+ }
+ }
+
+ return (
+
+
+ {/* Favicon */}
+ {link.favicon && (
+ // eslint-disable-next-line @next/next/no-img-element
+
(e.currentTarget.style.display = 'none')}
+ />
+ )}
+
+
+ {/* Titre ou domaine */}
+
+ {link.title || domain}
+
+
+ {/* Lien raccourci */}
+
+ {shortUrl}
+
+
+ {/* URL originale */}
+ {showFullUrl && (
+
+ {link.originalUrl}
+
+ )}
+
+ {/* Méta infos */}
+
+
+
+
+
+
+ {formatNumber(link.clickCount)} clic{link.clickCount !== 1 ? 's' : ''}
+
+
+
+
+
+ {formatRelativeDate(link.createdAt)}
+
+
+
+
+
+ {domain}
+
+
+
+
+ {/* Bouton copier */}
+
+ {copied ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/LinkFilters.tsx b/src/components/LinkFilters.tsx
new file mode 100644
index 0000000..0e92150
--- /dev/null
+++ b/src/components/LinkFilters.tsx
@@ -0,0 +1,110 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import { useRouter } from 'next/navigation'
+import type { SortOption } from '@/lib/types'
+
+interface LinkFiltersProps {
+ initialSort: SortOption
+ initialSearch: string
+}
+
+// Filtres et recherche pour la liste des liens
+export default function LinkFilters({ initialSort, initialSearch }: LinkFiltersProps) {
+ const router = useRouter()
+ const [search, setSearch] = useState(initialSearch)
+ const [sort, setSort] = useState(initialSort)
+
+ // Mettre Ă jour l'URL avec les paramĂštres
+ const updateUrl = useCallback((newSort: SortOption, newSearch: string) => {
+ const params = new URLSearchParams()
+ if (newSort !== 'recent') params.set('sort', newSort)
+ if (newSearch) params.set('search', newSearch)
+ params.set('page', '1')
+
+ const queryString = params.toString()
+ router.push(`/liens${queryString ? `?${queryString}` : ''}`)
+ }, [router])
+
+ // Debounce pour la recherche
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (search !== initialSearch) {
+ updateUrl(sort, search)
+ }
+ }, 300)
+ return () => clearTimeout(timer)
+ }, [search, sort, initialSearch, updateUrl])
+
+ const handleSortChange = (newSort: SortOption) => {
+ setSort(newSort)
+ updateUrl(newSort, search)
+ }
+
+ const sortOptions: { value: SortOption; label: string }[] = [
+ { value: 'recent', label: 'Plus récents' },
+ { value: 'popular', label: 'Plus cliqués' },
+ { value: 'alphabetical', label: 'Alphabétique' },
+ ]
+
+ return (
+
+ {/* Barre de recherche */}
+
+
+
+
+
setSearch(e.target.value)}
+ placeholder="Rechercher un lien..."
+ className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-400 transition-colors focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
+ aria-label="Rechercher"
+ />
+ {search && (
+
{
+ setSearch('')
+ updateUrl(sort, '')
+ }}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
+ aria-label="Effacer la recherche"
+ >
+
+
+
+
+ )}
+
+
+ {/* Tri */}
+
+ {sortOptions.map((option) => (
+ handleSortChange(option.value)}
+ className={`rounded-lg px-4 py-2.5 text-sm font-medium transition-colors ${
+ sort === option.value
+ ? 'bg-blue-600 text-white'
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
+ }`}
+ aria-pressed={sort === option.value}
+ >
+ {option.label}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/LinkForm.tsx b/src/components/LinkForm.tsx
new file mode 100644
index 0000000..14f1de1
--- /dev/null
+++ b/src/components/LinkForm.tsx
@@ -0,0 +1,222 @@
+'use client'
+
+import { useState, useRef, useCallback } from 'react'
+import { createLink, previewLink } from '@/lib/actions'
+import type { CreateLinkResponse, LinkPreview } from '@/lib/types'
+import { getShortUrl } from '@/lib/utils'
+import LinkResult from './LinkResult'
+
+// Formulaire de création de liens
+export default function LinkForm() {
+ const [url, setUrl] = useState('')
+ const [customAlias, setCustomAlias] = useState('')
+ const [useCustomAlias, setUseCustomAlias] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
+ const [result, setResult] = useState(null)
+ const [preview, setPreview] = useState(null)
+ const [previewLoading, setPreviewLoading] = useState(false)
+ const debounceRef = useRef(null)
+
+ // Aperçu du lien avec debounce
+ const handleUrlChange = useCallback((value: string) => {
+ setUrl(value)
+ setResult(null)
+
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current)
+ }
+
+ if (value.length > 10) {
+ debounceRef.current = setTimeout(async () => {
+ setPreviewLoading(true)
+ try {
+ const previewData = await previewLink(value)
+ setPreview(previewData as LinkPreview)
+ } catch {
+ setPreview(null)
+ } finally {
+ setPreviewLoading(false)
+ }
+ }, 500)
+ } else {
+ setPreview(null)
+ }
+ }, [])
+
+ // Soumission du formulaire
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setIsLoading(true)
+ setResult(null)
+
+ try {
+ const response = await createLink(url, useCustomAlias ? customAlias : undefined)
+ setResult(response)
+
+ if (response.success) {
+ // Ne pas réinitialiser le formulaire pour permettre de voir le résultat
+ }
+ } catch {
+ setResult({
+ success: false,
+ error: 'Une erreur est survenue. Veuillez réessayer.',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // Réinitialiser le formulaire
+ const handleReset = () => {
+ setUrl('')
+ setCustomAlias('')
+ setUseCustomAlias(false)
+ setResult(null)
+ setPreview(null)
+ }
+
+ return (
+
+
+
+ {/* Résultat */}
+ {result && (
+
+ {result.success && result.link ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/components/LinkResult.tsx b/src/components/LinkResult.tsx
new file mode 100644
index 0000000..6a648e6
--- /dev/null
+++ b/src/components/LinkResult.tsx
@@ -0,0 +1,200 @@
+'use client'
+
+import { useState, useRef } from 'react'
+import { QRCodeCanvas } from 'qrcode.react'
+import type { Link } from '@/lib/types'
+import { formatNumber } from '@/lib/utils'
+
+interface LinkResultProps {
+ link: Link
+ shortUrl: string
+ isReused?: boolean
+ onReset: () => void
+}
+
+// Composant d'affichage du résultat aprÚs création de lien
+export default function LinkResult({ link, shortUrl, isReused, onReset }: LinkResultProps) {
+ const [copied, setCopied] = useState(false)
+ const [qrCopied, setQrCopied] = useState(false)
+ const qrRef = useRef(null)
+
+ // Copier le lien dans le presse-papier
+ const copyLink = async () => {
+ try {
+ await navigator.clipboard.writeText(shortUrl)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch (error) {
+ console.error('Erreur lors de la copie:', error)
+ }
+ }
+
+ // Copier le QR Code dans le presse-papier
+ const copyQRCode = async () => {
+ try {
+ const canvas = qrRef.current?.querySelector('canvas')
+ if (!canvas) return
+
+ const blob = await new Promise((resolve) => {
+ canvas.toBlob((b) => resolve(b!), 'image/png')
+ })
+
+ await navigator.clipboard.write([
+ new ClipboardItem({ 'image/png': blob }),
+ ])
+ setQrCopied(true)
+ setTimeout(() => setQrCopied(false), 2000)
+ } catch {
+ // Fallback: télécharger si la copie échoue
+ downloadQRCode()
+ }
+ }
+
+ // Télécharger le QR Code
+ const downloadQRCode = () => {
+ const canvas = qrRef.current?.querySelector('canvas')
+ if (!canvas) return
+
+ const url = canvas.toDataURL('image/png')
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `reducelink-${link.shortCode}-qr.png`
+ a.click()
+ }
+
+ return (
+
+ {/* Message de réutilisation */}
+ {isReused && (
+
+
+
+
+
Ce lien existait déjà , nous l'avons réutilisé.
+
+ )}
+
+ {/* En-tĂȘte succĂšs */}
+
+
+
+
Lien créé avec succÚs !
+
{formatNumber(link.clickCount)} clic{link.clickCount !== 1 ? 's' : ''}
+
+
+
+ {/* Lien raccourci */}
+
+
Votre lien raccourci
+
+
+
+ {copied ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {/* URL originale */}
+
+
URL originale
+
+ {link.originalUrl}
+
+
+
+ {/* QR Code */}
+
+
QR Code
+
+
+
+
+
+
+ {qrCopied ? (
+ <>
+
+
+
+ Copié !
+ >
+ ) : (
+ <>
+
+
+
+ Copier le QR Code
+ >
+ )}
+
+
+
+
+
+ Télécharger
+
+
+
+
+
+ {/* Nouveau lien */}
+
+
+ Créer un nouveau lien
+
+
+
+ )
+}
diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx
new file mode 100644
index 0000000..ec8a12f
--- /dev/null
+++ b/src/components/Pagination.tsx
@@ -0,0 +1,125 @@
+import Link from 'next/link'
+
+interface PaginationProps {
+ currentPage: number
+ totalPages: number
+ baseUrl: string
+ searchParams?: Record
+}
+
+// Composant de pagination
+export default function Pagination({ currentPage, totalPages, baseUrl, searchParams = {} }: PaginationProps) {
+ if (totalPages <= 1) return null
+
+ // Construire l'URL avec les paramĂštres
+ const buildUrl = (page: number) => {
+ const params = new URLSearchParams(searchParams)
+ params.set('page', page.toString())
+ return `${baseUrl}?${params.toString()}`
+ }
+
+ // Générer les numéros de pages à afficher
+ const getPageNumbers = () => {
+ const pages: (number | 'ellipsis')[] = []
+ const showEllipsis = totalPages > 7
+
+ if (!showEllipsis) {
+ for (let i = 1; i <= totalPages; i++) {
+ pages.push(i)
+ }
+ } else {
+ pages.push(1)
+
+ if (currentPage > 3) {
+ pages.push('ellipsis')
+ }
+
+ const start = Math.max(2, currentPage - 1)
+ const end = Math.min(totalPages - 1, currentPage + 1)
+
+ for (let i = start; i <= end; i++) {
+ pages.push(i)
+ }
+
+ if (currentPage < totalPages - 2) {
+ pages.push('ellipsis')
+ }
+
+ pages.push(totalPages)
+ }
+
+ return pages
+ }
+
+ const pageNumbers = getPageNumbers()
+
+ return (
+
+ {/* Bouton précédent */}
+ {currentPage > 1 ? (
+
+
+
+
+ Précédent
+
+ ) : (
+
+
+
+
+ Précédent
+
+ )}
+
+ {/* Numéros de page */}
+
+ {pageNumbers.map((page, index) =>
+ page === 'ellipsis' ? (
+
+ ...
+
+ ) : (
+
+ {page}
+
+ )
+ )}
+
+
+ {/* Bouton suivant */}
+ {currentPage < totalPages ? (
+
+ Suivant
+
+
+
+
+ ) : (
+
+ Suivant
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/StatsCards.tsx b/src/components/StatsCards.tsx
new file mode 100644
index 0000000..e731e98
--- /dev/null
+++ b/src/components/StatsCards.tsx
@@ -0,0 +1,81 @@
+import type { GlobalStats } from '@/lib/types'
+import { formatNumber } from '@/lib/utils'
+
+interface StatsCardsProps {
+ stats: GlobalStats
+}
+
+// Cartes de statistiques globales
+export default function StatsCards({ stats }: StatsCardsProps) {
+ const cards = [
+ {
+ label: 'Liens créés',
+ value: formatNumber(stats.totalLinks),
+ icon: (
+
+
+
+ ),
+ color: 'blue',
+ },
+ {
+ label: 'Redirections',
+ value: formatNumber(stats.totalClicks),
+ icon: (
+
+
+
+ ),
+ color: 'green',
+ },
+ {
+ label: "Liens aujourd'hui",
+ value: formatNumber(stats.linksToday),
+ icon: (
+
+
+
+ ),
+ color: 'purple',
+ },
+ {
+ label: "Clics aujourd'hui",
+ value: formatNumber(stats.clicksToday),
+ icon: (
+
+
+
+ ),
+ color: 'orange',
+ },
+ ]
+
+ const colorClasses = {
+ blue: 'bg-blue-100 text-blue-600',
+ green: 'bg-green-100 text-green-600',
+ purple: 'bg-purple-100 text-purple-600',
+ orange: 'bg-orange-100 text-orange-600',
+ }
+
+ return (
+
+ {cards.map((card, index) => (
+
+
+
+ {card.icon}
+
+
+
{card.value}
+
{card.label}
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/components/index.ts b/src/components/index.ts
new file mode 100644
index 0000000..b1407d0
--- /dev/null
+++ b/src/components/index.ts
@@ -0,0 +1,9 @@
+// Export de tous les composants
+export { default as Header } from './Header'
+export { default as Footer } from './Footer'
+export { default as LinkForm } from './LinkForm'
+export { default as LinkResult } from './LinkResult'
+export { default as LinkCard } from './LinkCard'
+export { default as LinkFilters } from './LinkFilters'
+export { default as StatsCards } from './StatsCards'
+export { default as Pagination } from './Pagination'
diff --git a/src/lib/actions.ts b/src/lib/actions.ts
new file mode 100644
index 0000000..995f215
--- /dev/null
+++ b/src/lib/actions.ts
@@ -0,0 +1,324 @@
+'use server'
+
+// Server Actions pour la gestion des liens
+
+import prisma from '@/lib/prisma'
+import {
+ generateRandomAlias,
+ sanitizeAlias,
+ isValidUrl,
+ isReservedAlias,
+ isSuspiciousUrl,
+ getFaviconUrl,
+ extractDomain,
+} from '@/lib/utils'
+import type { CreateLinkResponse, GlobalStats, Link, PaginatedLinks, SortOption } from '@/lib/types'
+import { revalidatePath } from 'next/cache'
+
+/**
+ * RécupÚre le titre d'une page web
+ */
+async function fetchPageTitle(url: string): Promise {
+ try {
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), 5000)
+
+ const response = await fetch(url, {
+ signal: controller.signal,
+ headers: {
+ 'User-Agent': 'ReduceLink Bot/1.0',
+ },
+ })
+
+ clearTimeout(timeoutId)
+
+ if (!response.ok) return null
+
+ const html = await response.text()
+ const titleMatch = html.match(/]*>([^<]+)<\/title>/i)
+ return titleMatch ? titleMatch[1].trim().slice(0, 200) : null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Crée un nouveau lien raccourci
+ */
+export async function createLink(url: string, customAlias?: string): Promise {
+ try {
+ // Validation de l'URL
+ if (!isValidUrl(url)) {
+ return { success: false, error: 'URL invalide. Veuillez entrer une URL valide commençant par http:// ou https://' }
+ }
+
+ // Vérification des liens suspects
+ const suspiciousCheck = isSuspiciousUrl(url)
+ if (suspiciousCheck.suspicious) {
+ return { success: false, error: `Lien suspect détecté: ${suspiciousCheck.reason}` }
+ }
+
+ let shortCode: string
+ let isReused = false
+
+ if (customAlias) {
+ // Alias personnalisé
+ shortCode = sanitizeAlias(customAlias)
+
+ if (shortCode.length < 3) {
+ return { success: false, error: "L'alias doit contenir au moins 3 caractĂšres" }
+ }
+
+ if (isReservedAlias(shortCode)) {
+ return { success: false, error: 'Cet alias est réservé. Veuillez en choisir un autre.' }
+ }
+
+ // VĂ©rifier si l'alias existe dĂ©jĂ
+ const existingLink = await prisma.link.findUnique({
+ where: { shortCode },
+ })
+
+ if (existingLink) {
+ // Si l'alias existe pour une URL différente
+ if (existingLink.originalUrl !== url) {
+ return { success: false, error: 'Cet alias est déjà utilisé. Veuillez en choisir un autre.' }
+ }
+ // MĂȘme URL, on rĂ©utilise
+ isReused = true
+ revalidatePath('/')
+ revalidatePath('/liens')
+ return {
+ success: true,
+ link: existingLink,
+ shortUrl: `/${existingLink.shortCode}`,
+ isReused: true,
+ }
+ }
+ } else {
+ // Alias alĂ©atoire - vĂ©rifier si l'URL existe dĂ©jĂ
+ const existingLink = await prisma.link.findFirst({
+ where: { originalUrl: url },
+ })
+
+ if (existingLink) {
+ // URL déjà raccourcie, on réutilise
+ revalidatePath('/')
+ revalidatePath('/liens')
+ return {
+ success: true,
+ link: existingLink,
+ shortUrl: `/${existingLink.shortCode}`,
+ isReused: true,
+ }
+ }
+
+ // Générer un nouvel alias aléatoire unique
+ let attempts = 0
+ do {
+ shortCode = generateRandomAlias()
+ const existing = await prisma.link.findUnique({
+ where: { shortCode },
+ })
+ if (!existing) break
+ attempts++
+ } while (attempts < 10)
+
+ if (attempts >= 10) {
+ return { success: false, error: "Impossible de générer un alias unique. Veuillez réessayer." }
+ }
+ }
+
+ // Récupérer les métadonnées de la page
+ const [title, favicon] = await Promise.all([
+ fetchPageTitle(url),
+ Promise.resolve(getFaviconUrl(url)),
+ ])
+
+ // Créer le nouveau lien
+ const newLink = await prisma.link.create({
+ data: {
+ originalUrl: url,
+ shortCode: shortCode!,
+ title,
+ favicon,
+ },
+ })
+
+ revalidatePath('/')
+ revalidatePath('/liens')
+ revalidatePath('/stats')
+
+ return {
+ success: true,
+ link: newLink,
+ shortUrl: `/${newLink.shortCode}`,
+ isReused,
+ }
+ } catch (error) {
+ console.error('Erreur lors de la création du lien:', error)
+ return { success: false, error: 'Une erreur est survenue. Veuillez réessayer.' }
+ }
+}
+
+/**
+ * RécupÚre un lien par son code court et incrémente le compteur
+ */
+export async function getLinkAndIncrementClick(shortCode: string): Promise {
+ try {
+ // D'abord vérifier si le lien existe
+ const existingLink = await prisma.link.findUnique({
+ where: { shortCode },
+ })
+
+ if (!existingLink) {
+ return null
+ }
+
+ // Puis incrémenter le compteur
+ const link = await prisma.link.update({
+ where: { shortCode },
+ data: { clickCount: { increment: 1 } },
+ })
+
+ revalidatePath('/liens')
+ revalidatePath('/stats')
+
+ return link
+ } catch (error) {
+ console.error('Erreur lors de la récupération du lien:', error)
+ return null
+ }
+}
+
+/**
+ * RécupÚre un lien par son code court sans incrémenter
+ */
+export async function getLinkByShortCode(shortCode: string): Promise {
+ try {
+ return await prisma.link.findUnique({
+ where: { shortCode },
+ })
+ } catch {
+ return null
+ }
+}
+
+/**
+ * RécupÚre les statistiques globales
+ */
+export async function getGlobalStats(): Promise {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+
+ const [totalLinks, totalClicks, linksToday, clicksToday] = await Promise.all([
+ prisma.link.count(),
+ prisma.link.aggregate({ _sum: { clickCount: true } }),
+ prisma.link.count({ where: { createdAt: { gte: today } } }),
+ prisma.link.aggregate({
+ _sum: { clickCount: true },
+ where: { createdAt: { gte: today } },
+ }),
+ ])
+
+ return {
+ totalLinks,
+ totalClicks: totalClicks._sum.clickCount || 0,
+ linksToday,
+ clicksToday: clicksToday._sum.clickCount || 0,
+ }
+}
+
+/**
+ * RécupÚre les liens paginés
+ */
+export async function getLinks(
+ page: number = 1,
+ pageSize: number = 20,
+ sort: SortOption = 'recent',
+ search?: string
+): Promise {
+ const skip = (page - 1) * pageSize
+
+ const orderBy: Record =
+ sort === 'recent' ? { createdAt: 'desc' } :
+ sort === 'popular' ? { clickCount: 'desc' } :
+ { shortCode: 'asc' }
+
+ const where = search
+ ? {
+ OR: [
+ { shortCode: { contains: search } },
+ { originalUrl: { contains: search } },
+ { title: { contains: search } },
+ ],
+ }
+ : {}
+
+ const [links, total] = await Promise.all([
+ prisma.link.findMany({
+ where,
+ orderBy,
+ skip,
+ take: pageSize,
+ }),
+ prisma.link.count({ where }),
+ ])
+
+ return {
+ links,
+ total,
+ page,
+ pageSize,
+ totalPages: Math.ceil(total / pageSize),
+ }
+}
+
+/**
+ * RécupÚre les liens les plus populaires
+ */
+export async function getPopularLinks(limit: number = 10): Promise {
+ return prisma.link.findMany({
+ orderBy: { clickCount: 'desc' },
+ take: limit,
+ })
+}
+
+/**
+ * RécupÚre les liens récents
+ */
+export async function getRecentLinks(limit: number = 10): Promise {
+ return prisma.link.findMany({
+ orderBy: { createdAt: 'desc' },
+ take: limit,
+ })
+}
+
+/**
+ * Aperçu d'un lien avant création
+ */
+export async function previewLink(url: string) {
+ if (!isValidUrl(url)) {
+ return { isValid: false, url }
+ }
+
+ const suspicious = isSuspiciousUrl(url)
+ const domain = extractDomain(url)
+ const favicon = getFaviconUrl(url)
+
+ let title: string | null = null
+ try {
+ title = await fetchPageTitle(url)
+ } catch {
+ // Ignore errors
+ }
+
+ return {
+ isValid: true,
+ url,
+ domain,
+ favicon,
+ title,
+ isSuspicious: suspicious.suspicious,
+ suspiciousReason: suspicious.reason,
+ }
+}
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
new file mode 100644
index 0000000..d123abf
--- /dev/null
+++ b/src/lib/prisma.ts
@@ -0,0 +1,18 @@
+// Singleton Prisma Client pour Next.js
+// Ăvite les multiples instances en dĂ©veloppement
+
+import { PrismaClient } from '@prisma/client'
+
+const globalForPrisma = globalThis as unknown as {
+ prisma: PrismaClient | undefined
+}
+
+export const prisma =
+ globalForPrisma.prisma ??
+ new PrismaClient({
+ log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
+ })
+
+if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+
+export default prisma
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
new file mode 100644
index 0000000..f173fbd
--- /dev/null
+++ b/src/lib/queries.ts
@@ -0,0 +1,46 @@
+// Fonctions de requĂȘte pour les pages (non Server Actions)
+
+import prisma from '@/lib/prisma'
+import type { Link } from '@/lib/types'
+
+/**
+ * RécupÚre un lien par son code court et incrémente le compteur
+ * Cette fonction est utilisée par la page de redirection
+ */
+export async function getLinkAndIncrementClick(shortCode: string): Promise {
+ try {
+ // D'abord vérifier si le lien existe
+ const existingLink = await prisma.link.findUnique({
+ where: { shortCode },
+ })
+
+ if (!existingLink) {
+ console.log(`Lien non trouvé pour shortCode: ${shortCode}`)
+ return null
+ }
+
+ // Puis incrémenter le compteur
+ const link = await prisma.link.update({
+ where: { shortCode },
+ data: { clickCount: { increment: 1 } },
+ })
+
+ return link
+ } catch (error) {
+ console.error('Erreur lors de la récupération du lien:', error)
+ return null
+ }
+}
+
+/**
+ * RécupÚre un lien par son code court sans incrémenter
+ */
+export async function getLinkByShortCode(shortCode: string): Promise {
+ try {
+ return await prisma.link.findUnique({
+ where: { shortCode },
+ })
+ } catch {
+ return null
+ }
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
new file mode 100644
index 0000000..0f94315
--- /dev/null
+++ b/src/lib/types.ts
@@ -0,0 +1,51 @@
+// Types TypeScript pour ReduceLink
+
+export interface Link {
+ id: number
+ originalUrl: string
+ shortCode: string
+ clickCount: number
+ createdAt: Date
+ title?: string | null
+ favicon?: string | null
+}
+
+export interface CreateLinkRequest {
+ url: string
+ customAlias?: string
+}
+
+export interface CreateLinkResponse {
+ success: boolean
+ link?: Link
+ shortUrl?: string
+ isReused?: boolean
+ error?: string
+}
+
+export interface GlobalStats {
+ totalLinks: number
+ totalClicks: number
+ linksToday: number
+ clicksToday: number
+}
+
+export interface LinkPreview {
+ url: string
+ domain: string
+ favicon: string
+ title?: string
+ isValid: boolean
+ isSuspicious: boolean
+ suspiciousReason?: string
+}
+
+export interface PaginatedLinks {
+ links: Link[]
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+}
+
+export type SortOption = 'recent' | 'popular' | 'alphabetical'
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..cbd3413
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,143 @@
+// Utilitaires pour la validation et génération de liens
+
+import { customAlphabet } from 'nanoid'
+
+// Alphabet pour les alias aléatoires (sans caractÚres ambigus)
+const alphabet = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'
+const nanoid = customAlphabet(alphabet, 6)
+
+/**
+ * GénÚre un alias aléatoire unique
+ */
+export function generateRandomAlias(): string {
+ return nanoid()
+}
+
+/**
+ * Valide et nettoie un alias personnalisé
+ */
+export function sanitizeAlias(alias: string): string {
+ return alias
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9-_]/g, '')
+ .slice(0, 50)
+}
+
+/**
+ * Valide une URL
+ */
+export function isValidUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url)
+ return ['http:', 'https:'].includes(parsed.protocol)
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Extrait le domaine d'une URL
+ */
+export function extractDomain(url: string): string {
+ try {
+ const parsed = new URL(url)
+ return parsed.hostname
+ } catch {
+ return ''
+ }
+}
+
+/**
+ * GénÚre l'URL du favicon pour un domaine
+ */
+export function getFaviconUrl(url: string): string {
+ const domain = extractDomain(url)
+ if (!domain) return ''
+ return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`
+}
+
+/**
+ * Vérifie si un alias est réservé (routes du site)
+ */
+export function isReservedAlias(alias: string): boolean {
+ const reserved = [
+ 'liens',
+ 'stats',
+ 'a-propos',
+ 'conditions',
+ 'api',
+ 'admin',
+ '_next',
+ 'favicon.ico',
+ 'robots.txt',
+ 'sitemap.xml',
+ ]
+ return reserved.includes(alias.toLowerCase())
+}
+
+/**
+ * Détecte les liens potentiellement suspects
+ */
+export function isSuspiciousUrl(url: string): { suspicious: boolean; reason?: string } {
+ const suspiciousPatterns = [
+ { pattern: /bit\.ly|tinyurl|goo\.gl|t\.co/i, reason: 'Lien déjà raccourci' },
+ { pattern: /\.(exe|bat|cmd|msi|dll)$/i, reason: 'Fichier exécutable' },
+ { pattern: /javascript:/i, reason: 'Script JavaScript' },
+ { pattern: /data:/i, reason: 'URL data' },
+ ]
+
+ for (const { pattern, reason } of suspiciousPatterns) {
+ if (pattern.test(url)) {
+ return { suspicious: true, reason }
+ }
+ }
+
+ return { suspicious: false }
+}
+
+/**
+ * Formate un nombre avec des séparateurs de milliers
+ */
+export function formatNumber(num: number): string {
+ return new Intl.NumberFormat('fr-FR').format(num)
+}
+
+/**
+ * Formate une date en français
+ */
+export function formatDate(date: Date): string {
+ return new Intl.DateTimeFormat('fr-FR', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ }).format(new Date(date))
+}
+
+/**
+ * Formate une date relative
+ */
+export function formatRelativeDate(date: Date): string {
+ const now = new Date()
+ const diff = now.getTime() - new Date(date).getTime()
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24))
+
+ if (days === 0) return "Aujourd'hui"
+ if (days === 1) return 'Hier'
+ if (days < 7) return `Il y a ${days} jours`
+ if (days < 30) return `Il y a ${Math.floor(days / 7)} semaine${Math.floor(days / 7) > 1 ? 's' : ''}`
+ if (days < 365) return `Il y a ${Math.floor(days / 30)} mois`
+ return `Il y a ${Math.floor(days / 365)} an${Math.floor(days / 365) > 1 ? 's' : ''}`
+}
+
+/**
+ * Base URL du site
+ */
+export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://reducelink.arthurp.fr'
+
+/**
+ * GénÚre l'URL raccourcie complÚte
+ */
+export function getShortUrl(shortCode: string): string {
+ return `${BASE_URL}/${shortCode}`
+}