mirror of
https://github.com/arthur-pbty/reducelink.git
synced 2026-06-03 15:07:36 +02:00
feat: add LinkResult, Pagination, and StatsCards components; implement server actions for link management and global stats retrieval
This commit is contained in:
@@ -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"
|
||||
+10
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
+49
-1
@@ -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;
|
||||
|
||||
Generated
+459
-58
@@ -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",
|
||||
|
||||
+10
-3
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
@@ -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'
|
||||
@@ -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 (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
|
||||
{/* En-tête */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 sm:text-4xl">
|
||||
À propos de ReduceLink
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-gray-600">
|
||||
Un service simple et transparent de raccourcissement de liens
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="prose prose-gray max-w-none">
|
||||
{/* Mission */}
|
||||
<section className="mb-12">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 sm:p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
Notre mission
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
ReduceLink est né d'une idée simple : proposer un service de raccourcissement
|
||||
de liens <strong>gratuit</strong>, <strong>sans inscription</strong> et <strong>respectueux
|
||||
de la vie privée</strong>. 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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pas de comptes */}
|
||||
<section className="mb-12">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 sm:p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100 text-green-600">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
Pas de compte utilisateur
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-4">
|
||||
Contrairement à d'autres services, ReduceLink ne requiert <strong>aucune inscription</strong>.
|
||||
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.
|
||||
</p>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="flex items-center gap-2">
|
||||
<svg className="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Aucune inscription nécessaire
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<svg className="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Utilisation immédiate
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<svg className="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Aucune donnée personnelle collectée
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transparence */}
|
||||
<section className="mb-12">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 sm:p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100 text-purple-600">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
Transparence totale
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-4">
|
||||
Tous les liens créés sur ReduceLink sont <strong>publics</strong>. 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é.
|
||||
</p>
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-900 mb-2">Pourquoi la transparence ?</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Vie privée */}
|
||||
<section className="mb-12">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 sm:p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 text-orange-600">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</span>
|
||||
Respect de la vie privée
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-4">
|
||||
Nous ne collectons <strong>aucune donnée personnelle</strong>. Pas de cookies de tracking,
|
||||
pas d'adresse IP enregistrée, pas d'empreinte digitale du navigateur.
|
||||
Les seules informations stockées sont :
|
||||
</p>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
L'URL originale
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
L'alias court
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
Le nombre de clics (anonyme)
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
La date de création
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comment ça marche */}
|
||||
<section className="mb-12">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 sm:p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-cyan-100 text-cyan-600">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</span>
|
||||
Comment ça marche
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="text-center p-4">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 text-blue-600 font-bold text-lg">
|
||||
1
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">Collez votre lien</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Entrez l'URL longue que vous souhaitez raccourcir
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-4">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 text-blue-600 font-bold text-lg">
|
||||
2
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">Choisissez un alias</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Personnalisé ou généré automatiquement
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-4">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 text-blue-600 font-bold text-lg">
|
||||
3
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">Partagez !</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Copiez le lien ou téléchargez le QR Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="text-center">
|
||||
<div className="rounded-xl bg-blue-600 p-8 text-white">
|
||||
<h2 className="text-2xl font-bold mb-4">Prêt à raccourcir vos liens ?</h2>
|
||||
<p className="mb-6 text-blue-100">
|
||||
C'est gratuit, instantané et sans inscription.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-white px-6 py-3 font-semibold text-blue-600 transition-transform hover:scale-105"
|
||||
>
|
||||
Commencer maintenant
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
fontSize: 120,
|
||||
background: 'linear-gradient(135deg, #1e40af 0%, #2563eb 100%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
borderRadius: '36px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
R
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Accueil", item: "https://reducelink.arthurp.fr" },
|
||||
{ "@type": "ListItem", position: 2, name: "Conditions d'utilisation", item: "https://reducelink.arthurp.fr/conditions" },
|
||||
],
|
||||
}) }}
|
||||
/>
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
|
||||
{/* En-tête */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 sm:text-4xl">
|
||||
Conditions d'utilisation
|
||||
</h1>
|
||||
<p className="mt-4 text-gray-600">
|
||||
Dernière mise à jour : Février 2026
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contenu */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 sm:p-8">
|
||||
<div className="prose prose-gray max-w-none">
|
||||
{/* Introduction */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">1. Acceptation des conditions</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Description du service */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">2. Description du service</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-4">
|
||||
ReduceLink est un service gratuit de raccourcissement de liens. Il permet de :
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-gray-600">
|
||||
<li>Transformer une URL longue en lien court</li>
|
||||
<li>Choisir un alias personnalisé ou généré automatiquement</li>
|
||||
<li>Obtenir un QR Code pour chaque lien</li>
|
||||
<li>Consulter les statistiques de clics</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Liens publics */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">3. Caractère public des liens</h2>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-yellow-800 font-medium">
|
||||
⚠️ Tous les liens créés sur ReduceLink sont publics.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Cela signifie que n'importe qui peut :
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-gray-600 mt-2">
|
||||
<li>Voir la liste de tous les liens créés</li>
|
||||
<li>Consulter l'URL de destination de chaque lien</li>
|
||||
<li>Voir le nombre de clics sur chaque lien</li>
|
||||
</ul>
|
||||
<p className="text-gray-600 leading-relaxed mt-4">
|
||||
<strong>Ne créez pas de liens vers des contenus sensibles ou privés.</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Pas de suppression */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">4. Permanence des liens</h2>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-blue-800 font-medium">
|
||||
ℹ️ Les liens créés ne peuvent pas être supprimés.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Une fois un lien créé, il est permanent. Cette règle garantit :
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-gray-600 mt-2">
|
||||
<li>La fiabilité des liens partagés</li>
|
||||
<li>La cohérence des statistiques</li>
|
||||
<li>La simplicité du service (pas de gestion de compte)</li>
|
||||
</ul>
|
||||
<p className="text-gray-600 leading-relaxed mt-4">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Contenus interdits */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">5. Contenus interdits</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-4">
|
||||
Il est interdit de créer des liens vers :
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-gray-600">
|
||||
<li>Contenus illégaux</li>
|
||||
<li>Malwares, virus ou logiciels malveillants</li>
|
||||
<li>Phishing ou arnaques</li>
|
||||
<li>Contenus haineux ou discriminatoires</li>
|
||||
<li>Contenus portant atteinte aux droits d'autrui</li>
|
||||
</ul>
|
||||
<p className="text-gray-600 leading-relaxed mt-4">
|
||||
Nous nous réservons le droit de désactiver tout lien violant ces règles.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Responsabilité */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">6. Limitation de responsabilité</h2>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
<strong>ReduceLink n'est pas responsable du contenu des liens.</strong>
|
||||
</p>
|
||||
<p className="text-gray-600 mt-2">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Disponibilité */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">7. Disponibilité du service</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Gratuité */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">8. Gratuité</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
ReduceLink est et restera <strong>100% gratuit</strong>. Aucun plan payant,
|
||||
aucune fonctionnalité premium, aucune publicité intrusive. Le service est
|
||||
financé de manière indépendante.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Modifications */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">9. Modifications des conditions</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Contact */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">10. Contact</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Pour toute question concernant ces conditions d'utilisation ou le service
|
||||
en général, consultez notre page{' '}
|
||||
<Link href="/a-propos" className="text-blue-600 hover:text-blue-700 underline">
|
||||
À propos
|
||||
</Link>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Résumé */}
|
||||
<div className="mt-8 rounded-xl bg-gray-100 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">En résumé</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Service gratuit et sans inscription
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Tous les liens sont publics
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Pas de suppression possible
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Contenus illégaux interdits
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Nous ne sommes pas responsables du contenu des liens
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+62
-12
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
fontSize: 24,
|
||||
background: '#2563eb',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
borderRadius: '6px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
R
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
)
|
||||
}
|
||||
+157
-15
@@ -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 (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="fr" dir="ltr">
|
||||
<head>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="dns-prefetch" href="https://www.google.com" />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased bg-gray-50 text-gray-900 min-h-screen flex flex-col`}>
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Accueil", item: "https://reducelink.arthurp.fr" },
|
||||
{ "@type": "ListItem", position: 2, name: "Tous les liens", item: "https://reducelink.arthurp.fr/liens" },
|
||||
],
|
||||
}) }}
|
||||
/>
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
|
||||
{/* En-tête */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 sm:text-4xl">
|
||||
Tous les liens
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
{formatNumber(total)} lien{total !== 1 ? 's' : ''} raccourci{total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<Suspense fallback={<div className="h-12 bg-gray-100 animate-pulse rounded-lg" />}>
|
||||
<LinkFilters initialSort={sort} initialSearch={search} />
|
||||
</Suspense>
|
||||
|
||||
{/* Liste des liens */}
|
||||
{links.length > 0 ? (
|
||||
<>
|
||||
<div className="grid gap-4 mb-8">
|
||||
{links.map((link) => (
|
||||
<LinkCard key={link.id} link={link} showFullUrl />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
baseUrl="/liens"
|
||||
searchParams={{
|
||||
...(sort !== 'recent' && { sort }),
|
||||
...(search && { search }),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-12 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-4 text-lg font-semibold text-gray-900">
|
||||
Aucun lien trouvé
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-600">
|
||||
{search
|
||||
? `Aucun résultat pour "${search}". Essayez avec d'autres termes.`
|
||||
: "Aucun lien n'a encore été créé. Soyez le premier !"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:py-24">
|
||||
{/* Illustration */}
|
||||
<div className="mb-8">
|
||||
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-red-100">
|
||||
<svg
|
||||
className="h-12 w-12 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code d'erreur */}
|
||||
<p className="text-7xl font-bold text-gray-900 sm:text-9xl">404</p>
|
||||
|
||||
{/* Message */}
|
||||
<h1 className="mt-4 text-2xl font-bold text-gray-900 sm:text-3xl">
|
||||
Lien introuvable
|
||||
</h1>
|
||||
<p className="mt-4 text-gray-600">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
Créer un lien
|
||||
</Link>
|
||||
<Link
|
||||
href="/liens"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
Rechercher un lien
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Aide */}
|
||||
<div className="mt-12 rounded-xl bg-gray-100 p-6 text-left">
|
||||
<h2 className="font-semibold text-gray-900 mb-3">Que faire ?</h2>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-start gap-2">
|
||||
<svg className="h-5 w-5 text-gray-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Vérifiez que l'URL est correctement écrite
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg className="h-5 w-5 text-gray-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Utilisez la barre de recherche pour trouver le lien
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg className="h-5 w-5 text-gray-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Si le lien a été partagé, demandez à la personne de vérifier
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg className="h-5 w-5 text-gray-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Créez un nouveau lien si nécessaire
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1e40af 0%, #3b82f6 50%, #60a5fa 100%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontWeight: 800,
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
Reduce
|
||||
<span style={{ color: '#bfdbfe' }}>Link</span>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: '#dbeafe',
|
||||
textAlign: 'center',
|
||||
maxWidth: '800px',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
Raccourcisseur de liens gratuit
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '32px',
|
||||
marginTop: '40px',
|
||||
fontSize: 22,
|
||||
color: '#e0e7ff',
|
||||
}}
|
||||
>
|
||||
<span>✓ Gratuit</span>
|
||||
<span>✓ Sans inscription</span>
|
||||
<span>✓ QR Code</span>
|
||||
<span>✓ Statistiques</span>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '32px',
|
||||
fontSize: 24,
|
||||
color: '#93c5fd',
|
||||
}}
|
||||
>
|
||||
reducelink.arthurp.fr
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
)
|
||||
}
|
||||
+290
-63
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
|
||||
{/* Hero Section */}
|
||||
<section className="text-center mb-12" aria-label="Présentation de ReduceLink">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl lg:text-6xl">
|
||||
Raccourcissez vos liens
|
||||
<span className="block text-blue-600">en un instant</span>
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
Transformez vos URLs longues en liens courts et mémorables.
|
||||
Gratuit, sans inscription, avec QR Code et statistiques.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Formulaire de création */}
|
||||
<section className="mb-16">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-lg sm:p-8 border border-gray-200">
|
||||
<LinkForm />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Statistiques globales */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">
|
||||
ReduceLink en chiffres
|
||||
</h2>
|
||||
<StatsCards stats={stats} />
|
||||
</section>
|
||||
|
||||
{/* Fonctionnalités */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">
|
||||
Pourquoi choisir ReduceLink ?
|
||||
</h2>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<FeatureCard
|
||||
icon={
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
}
|
||||
title="Rapide et simple"
|
||||
description="Collez votre lien, cliquez, c'est fait. Pas de compte à créer, pas de configuration."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
}
|
||||
title="QR Code inclus"
|
||||
description="Chaque lien génère automatiquement un QR Code que vous pouvez copier ou télécharger."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
}
|
||||
title="Statistiques"
|
||||
description="Suivez le nombre de clics sur vos liens. Toutes les statistiques sont publiques et transparentes."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
}
|
||||
title="Alias personnalisé"
|
||||
description="Choisissez votre propre alias pour des liens mémorables et professionnels."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
}
|
||||
title="Respectueux de la vie privée"
|
||||
description="Aucune donnée personnelle collectée. Pas de cookies de tracking. Juste des liens."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
}
|
||||
title="100% gratuit"
|
||||
description="Aucun plan payant, aucune limitation. ReduceLink est et restera gratuit."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Liens récents */}
|
||||
{recentLinks.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Liens récents</h2>
|
||||
<Link
|
||||
href="/liens"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
||||
>
|
||||
Voir tous les liens
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{recentLinks.map((link) => (
|
||||
<LinkCard key={link.id} link={link} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* FAQ Section - visible pour le SEO */}
|
||||
<section className="mt-16" aria-label="Questions fréquentes">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">
|
||||
Questions fréquentes
|
||||
</h2>
|
||||
<div className="space-y-4 max-w-3xl mx-auto">
|
||||
<details className="rounded-xl border border-gray-200 bg-white p-6 group">
|
||||
<summary className="font-semibold text-gray-900 cursor-pointer list-none flex items-center justify-between">
|
||||
Comment raccourcir un lien avec ReduceLink ?
|
||||
<svg className="h-5 w-5 text-gray-500 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</summary>
|
||||
<p className="mt-3 text-gray-600">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details className="rounded-xl border border-gray-200 bg-white p-6 group">
|
||||
<summary className="font-semibold text-gray-900 cursor-pointer list-none flex items-center justify-between">
|
||||
Est-ce que ReduceLink est gratuit ?
|
||||
<svg className="h-5 w-5 text-gray-500 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</summary>
|
||||
<p className="mt-3 text-gray-600">
|
||||
Oui, ReduceLink est 100% gratuit, sans limitation et sans inscription requise. Aucun plan payant n'existe.
|
||||
</p>
|
||||
</details>
|
||||
<details className="rounded-xl border border-gray-200 bg-white p-6 group">
|
||||
<summary className="font-semibold text-gray-900 cursor-pointer list-none flex items-center justify-between">
|
||||
Puis-je personnaliser mes liens courts ?
|
||||
<svg className="h-5 w-5 text-gray-500 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</summary>
|
||||
<p className="mt-3 text-gray-600">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details className="rounded-xl border border-gray-200 bg-white p-6 group">
|
||||
<summary className="font-semibold text-gray-900 cursor-pointer list-none flex items-center justify-between">
|
||||
Les liens raccourcis expirent-ils ?
|
||||
<svg className="h-5 w-5 text-gray-500 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</summary>
|
||||
<p className="mt-3 text-gray-600">
|
||||
Non, les liens créés sur ReduceLink sont permanents et ne peuvent pas être supprimés. Ils resteront actifs indéfiniment.
|
||||
</p>
|
||||
</details>
|
||||
<details className="rounded-xl border border-gray-200 bg-white p-6 group">
|
||||
<summary className="font-semibold text-gray-900 cursor-pointer list-none flex items-center justify-between">
|
||||
ReduceLink propose-t-il des statistiques ?
|
||||
<svg className="h-5 w-5 text-gray-500 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</summary>
|
||||
<p className="mt-3 text-gray-600">
|
||||
Oui, chaque lien dispose de statistiques de clics publiques et transparentes. Vous pouvez suivre le nombre de redirections en temps réel.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Composant carte fonctionnalité
|
||||
function FeatureCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 transition-shadow hover:shadow-md">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100 text-blue-600 mb-4">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-600">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Accueil", item: "https://reducelink.arthurp.fr" },
|
||||
{ "@type": "ListItem", position: 2, name: "Statistiques", item: "https://reducelink.arthurp.fr/stats" },
|
||||
],
|
||||
}) }}
|
||||
/>
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
|
||||
{/* En-tête */}
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 sm:text-4xl">
|
||||
Statistiques
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Les chiffres de ReduceLink en temps réel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cartes statistiques */}
|
||||
<section className="mb-12">
|
||||
<StatsCards stats={stats} />
|
||||
</section>
|
||||
|
||||
{/* Grille des classements */}
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
{/* Liens les plus populaires */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<svg className="h-5 w-5 text-orange-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Liens les plus populaires
|
||||
</h2>
|
||||
<Link
|
||||
href="/liens?sort=popular"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Voir tout
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{popularLinks.length > 0 ? (
|
||||
popularLinks.map((link, index) => (
|
||||
<div key={link.id} className="flex items-center gap-3">
|
||||
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-bold ${
|
||||
index === 0 ? 'bg-yellow-100 text-yellow-700' :
|
||||
index === 1 ? 'bg-gray-200 text-gray-700' :
|
||||
index === 2 ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<LinkCard link={link} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
Aucun lien n'a encore reçu de clics
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Liens récents */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<svg className="h-5 w-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Derniers liens créés
|
||||
</h2>
|
||||
<Link
|
||||
href="/liens?sort=recent"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Voir tout
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentLinks.length > 0 ? (
|
||||
recentLinks.map((link) => (
|
||||
<LinkCard key={link.id} link={link} />
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
Aucun lien n'a encore été créé
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Informations supplémentaires */}
|
||||
<section className="mt-12 rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
À propos de ces statistiques
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 text-sm text-gray-600">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-green-500 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Données en temps réel</p>
|
||||
<p>Les statistiques sont mises à jour instantanément à chaque clic.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-green-500 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">100% transparent</p>
|
||||
<p>Toutes les données sont publiques et accessibles à tous.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-green-500 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Aucun tracking</p>
|
||||
<p>Nous comptons uniquement les clics, pas les utilisateurs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-green-500 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Liens permanents</p>
|
||||
<p>Les liens créés ne sont jamais supprimés.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1e40af 0%, #3b82f6 50%, #60a5fa 100%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontWeight: 800,
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
Reduce
|
||||
<span style={{ color: '#bfdbfe' }}>Link</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: '#dbeafe',
|
||||
textAlign: 'center',
|
||||
maxWidth: '800px',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
Raccourcisseur de liens gratuit
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '32px',
|
||||
marginTop: '40px',
|
||||
fontSize: 22,
|
||||
color: '#e0e7ff',
|
||||
}}
|
||||
>
|
||||
<span>✓ Gratuit</span>
|
||||
<span>✓ Sans inscription</span>
|
||||
<span>✓ QR Code</span>
|
||||
<span>✓ Statistiques</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '32px',
|
||||
fontSize: 24,
|
||||
color: '#93c5fd',
|
||||
}}
|
||||
>
|
||||
reducelink.arthurp.fr
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
// Composant Footer
|
||||
export default function Footer() {
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer className="mt-auto border-t border-gray-200 bg-gray-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
{/* À propos */}
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<svg
|
||||
className="h-6 w-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
ReduceLink
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Service gratuit de raccourcissement de liens. Simple, rapide et sans inscription.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Liens rapides */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Liens rapides</h3>
|
||||
<ul className="mt-2 space-y-2 text-sm">
|
||||
<li>
|
||||
<Link href="/liens" className="text-gray-600 transition-colors hover:text-blue-600">
|
||||
Tous les liens
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/stats" className="text-gray-600 transition-colors hover:text-blue-600">
|
||||
Statistiques
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/a-propos" className="text-gray-600 transition-colors hover:text-blue-600">
|
||||
À propos
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/conditions" className="text-gray-600 transition-colors hover:text-blue-600">
|
||||
Conditions d'utilisation
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Information</h3>
|
||||
<ul className="mt-2 space-y-2 text-sm text-gray-600">
|
||||
<li>✓ Gratuit et sans inscription</li>
|
||||
<li>✓ Liens publics et permanents</li>
|
||||
<li>✓ Statistiques transparentes</li>
|
||||
<li>✓ Respectueux de la vie privée</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyright */}
|
||||
<div className="mt-8 border-t border-gray-200 pt-8 text-center text-sm text-gray-600">
|
||||
<p>
|
||||
© {currentYear} ReduceLink. Tous droits réservés.
|
||||
<span className="mx-2">•</span>
|
||||
<Link href="/conditions" className="hover:text-blue-600">
|
||||
Conditions
|
||||
</Link>
|
||||
<span className="mx-2">•</span>
|
||||
<Link href="/a-propos" className="hover:text-blue-600">
|
||||
À propos
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-gray-200 bg-white/80 backdrop-blur-md">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2 transition-opacity hover:opacity-80">
|
||||
<svg
|
||||
className="h-8 w-8 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-xl font-bold text-gray-900">
|
||||
Reduce<span className="text-blue-600">Link</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="hidden md:flex md:items-center md:gap-1" aria-label="Navigation principale">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
pathname === link.href
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
aria-current={pathname === link.href ? 'page' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Menu mobile */}
|
||||
<MobileMenu navLinks={navLinks} pathname={pathname} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileMenu({ navLinks, pathname }: { navLinks: { href: string; label: string }[]; pathname: string }) {
|
||||
return (
|
||||
<div className="md:hidden">
|
||||
<details className="group">
|
||||
<summary className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-gray-600 hover:bg-gray-100 list-none">
|
||||
<svg
|
||||
className="h-6 w-6 group-open:hidden"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg
|
||||
className="hidden h-6 w-6 group-open:block"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span className="sr-only">Menu</span>
|
||||
</summary>
|
||||
<div className="absolute left-0 right-0 top-16 border-b border-gray-200 bg-white p-4 shadow-lg">
|
||||
<nav className="flex flex-col gap-1">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`rounded-lg px-4 py-3 text-sm font-medium transition-colors ${
|
||||
pathname === link.href
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="group rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:border-blue-200 hover:shadow-md">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Favicon */}
|
||||
{link.favicon && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={link.favicon}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded-lg bg-gray-100 object-contain p-1"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Titre ou domaine */}
|
||||
<h3 className="font-medium text-gray-900 truncate">
|
||||
{link.title || domain}
|
||||
</h3>
|
||||
|
||||
{/* Lien raccourci */}
|
||||
<Link
|
||||
href={`/${link.shortCode}`}
|
||||
target="_blank"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 hover:underline"
|
||||
>
|
||||
{shortUrl}
|
||||
</Link>
|
||||
|
||||
{/* URL originale */}
|
||||
{showFullUrl && (
|
||||
<p className="mt-1 text-xs text-gray-500 truncate">
|
||||
{link.originalUrl}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Méta infos */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
{formatNumber(link.clickCount)} clic{link.clickCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{formatRelativeDate(link.createdAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-gray-400">
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
{domain}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bouton copier */}
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className={`shrink-0 rounded-lg p-2 transition-all ${
|
||||
copied
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-blue-100 hover:text-blue-600'
|
||||
}`}
|
||||
aria-label="Copier le lien"
|
||||
title="Copier le lien"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<SortOption>(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 (
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
{/* Barre de recherche */}
|
||||
<div className="relative flex-1">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tri */}
|
||||
<div className="flex gap-2">
|
||||
{sortOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<CreateLinkResponse | null>(null)
|
||||
const [preview, setPreview] = useState<LinkPreview | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(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 (
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Champ URL */}
|
||||
<div>
|
||||
<label htmlFor="url" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
URL à raccourcir
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="url"
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder="https://exemple.com/votre-lien-tres-long"
|
||||
className="w-full rounded-xl border border-gray-300 px-4 py-4 pr-12 text-gray-900 placeholder-gray-400 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
required
|
||||
disabled={isLoading}
|
||||
aria-describedby="url-description"
|
||||
/>
|
||||
{previewLoading && (
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||
<svg className="h-5 w-5 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p id="url-description" className="mt-1 text-xs text-gray-500">
|
||||
Collez votre URL longue ici
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Aperçu du lien */}
|
||||
{preview && preview.isValid && (
|
||||
<div className={`rounded-xl border p-4 transition-all ${
|
||||
preview.isSuspicious ? 'border-yellow-300 bg-yellow-50' : 'border-gray-200 bg-gray-50'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{preview.favicon && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={preview.favicon}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{preview.title || preview.domain}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">{preview.domain}</p>
|
||||
</div>
|
||||
</div>
|
||||
{preview.isSuspicious && (
|
||||
<div className="mt-3 flex items-center gap-2 text-yellow-700">
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm">{preview.suspiciousReason}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Option alias personnalisé */}
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCustomAlias}
|
||||
onChange={(e) => setUseCustomAlias(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Utiliser un alias personnalisé</span>
|
||||
</label>
|
||||
|
||||
{useCustomAlias && (
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-sm">
|
||||
reducelink.arthurp.fr/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={customAlias}
|
||||
onChange={(e) => setCustomAlias(e.target.value.toLowerCase().replace(/[^a-z0-9-_]/g, ''))}
|
||||
placeholder="mon-alias"
|
||||
className="w-full rounded-xl border border-gray-300 py-3 pl-32 pr-4 text-gray-900 placeholder-gray-400 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
minLength={3}
|
||||
maxLength={50}
|
||||
disabled={isLoading}
|
||||
aria-label="Alias personnalisé"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bouton soumettre */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !url}
|
||||
className="w-full rounded-xl bg-blue-600 px-6 py-4 text-base font-semibold text-white shadow-lg transition-all hover:bg-blue-700 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-400 disabled:shadow-none"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Création en cours...
|
||||
</span>
|
||||
) : (
|
||||
'Raccourcir le lien'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Résultat */}
|
||||
{result && (
|
||||
<div className="mt-8">
|
||||
{result.success && result.link ? (
|
||||
<LinkResult
|
||||
link={result.link}
|
||||
shortUrl={getShortUrl(result.link.shortCode)}
|
||||
isReused={result.isReused}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p className="text-sm text-red-700">{result.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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<Blob>((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 (
|
||||
<div className="rounded-2xl border border-green-200 bg-green-50 p-6 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
{/* Message de réutilisation */}
|
||||
{isReused && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-lg bg-blue-100 px-4 py-2 text-sm text-blue-700">
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Ce lien existait déjà, nous l'avons réutilisé.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* En-tête succès */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500">
|
||||
<svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-800">Lien créé avec succès !</h3>
|
||||
<p className="text-sm text-green-600">{formatNumber(link.clickCount)} clic{link.clickCount !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lien raccourci */}
|
||||
<div className="mb-6">
|
||||
<label className="text-sm font-medium text-gray-700 mb-1 block">Votre lien raccourci</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 rounded-lg border border-green-300 bg-white px-4 py-3">
|
||||
<a
|
||||
href={shortUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-700 font-medium break-all"
|
||||
>
|
||||
{shortUrl}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-lg transition-all ${
|
||||
copied
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-white border border-green-300 text-gray-600 hover:bg-green-100'
|
||||
}`}
|
||||
aria-label="Copier le lien"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL originale */}
|
||||
<div className="mb-6">
|
||||
<label className="text-sm font-medium text-gray-700 mb-1 block">URL originale</label>
|
||||
<p className="text-sm text-gray-600 truncate bg-white rounded-lg border border-gray-200 px-4 py-2">
|
||||
{link.originalUrl}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
<div className="border-t border-green-200 pt-6">
|
||||
<label className="text-sm font-medium text-gray-700 mb-3 block">QR Code</label>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div
|
||||
ref={qrRef}
|
||||
className="rounded-xl bg-white p-4 shadow-sm border border-gray-200"
|
||||
>
|
||||
<QRCodeCanvas
|
||||
value={shortUrl}
|
||||
size={150}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#1e40af"
|
||||
level="H"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={copyQRCode}
|
||||
className={`flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all ${
|
||||
qrCopied
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{qrCopied ? (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copié !
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copier le QR Code
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={downloadQRCode}
|
||||
className="flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-all hover:bg-gray-50"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nouveau lien */}
|
||||
<div className="mt-6 pt-6 border-t border-green-200">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="w-full rounded-lg border border-green-300 bg-white px-4 py-3 text-sm font-medium text-green-700 transition-all hover:bg-green-100"
|
||||
>
|
||||
Créer un nouveau lien
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
baseUrl: string
|
||||
searchParams?: Record<string, string>
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<nav className="flex items-center justify-center gap-1" aria-label="Pagination">
|
||||
{/* Bouton précédent */}
|
||||
{currentPage > 1 ? (
|
||||
<Link
|
||||
href={buildUrl(currentPage - 1)}
|
||||
className="flex h-10 items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
aria-label="Page précédente"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Précédent</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="flex h-10 cursor-not-allowed items-center gap-1 rounded-lg border border-gray-200 bg-gray-100 px-3 text-sm font-medium text-gray-400">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Précédent</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Numéros de page */}
|
||||
<div className="flex items-center gap-1">
|
||||
{pageNumbers.map((page, index) =>
|
||||
page === 'ellipsis' ? (
|
||||
<span key={`ellipsis-${index}`} className="px-2 text-gray-500">
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
key={page}
|
||||
href={buildUrl(page)}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg text-sm font-medium transition-colors ${
|
||||
page === currentPage
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bouton suivant */}
|
||||
{currentPage < totalPages ? (
|
||||
<Link
|
||||
href={buildUrl(currentPage + 1)}
|
||||
className="flex h-10 items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
aria-label="Page suivante"
|
||||
>
|
||||
<span className="hidden sm:inline">Suivant</span>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="flex h-10 cursor-not-allowed items-center gap-1 rounded-lg border border-gray-200 bg-gray-100 px-3 text-sm font-medium text-gray-400">
|
||||
<span className="hidden sm:inline">Suivant</span>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -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: (
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
),
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
label: 'Redirections',
|
||||
value: formatNumber(stats.totalClicks),
|
||||
icon: (
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
|
||||
</svg>
|
||||
),
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
label: "Liens aujourd'hui",
|
||||
value: formatNumber(stats.linksToday),
|
||||
icon: (
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
label: "Clics aujourd'hui",
|
||||
value: formatNumber(stats.clicksToday),
|
||||
icon: (
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
),
|
||||
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 (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`rounded-lg p-3 ${colorClasses[card.color as keyof typeof colorClasses]}`}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
|
||||
<p className="text-sm text-gray-600">{card.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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<string | null> {
|
||||
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[^>]*>([^<]+)<\/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<CreateLinkResponse> {
|
||||
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<Link | null> {
|
||||
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<Link | null> {
|
||||
try {
|
||||
return await prisma.link.findUnique({
|
||||
where: { shortCode },
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les statistiques globales
|
||||
*/
|
||||
export async function getGlobalStats(): Promise<GlobalStats> {
|
||||
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<PaginatedLinks> {
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const orderBy: Record<string, 'asc' | 'desc'> =
|
||||
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<Link[]> {
|
||||
return prisma.link.findMany({
|
||||
orderBy: { clickCount: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les liens récents
|
||||
*/
|
||||
export async function getRecentLinks(limit: number = 10): Promise<Link[]> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<Link | null> {
|
||||
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<Link | null> {
|
||||
try {
|
||||
return await prisma.link.findUnique({
|
||||
where: { shortCode },
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user