mirror of
https://github.com/arthur-pbty/links.git
synced 2026-08-01 20:29:27 +02:00
Refactor code structure and remove redundant sections for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=123456
|
||||
ADMIN_SESSION_SECRET=change-me-in-production
|
||||
PORT=3000
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "My Server",
|
||||
"host": "10.0.0.158",
|
||||
"protocol": "sftp",
|
||||
"port": 22,
|
||||
"username": "root",
|
||||
"remotePath": "/root/links",
|
||||
"uploadOnSave": false,
|
||||
"useTempFile": false,
|
||||
"openSsh": false
|
||||
}
|
||||
@@ -1,89 +1,93 @@
|
||||
# Social Links Website
|
||||
# Social Links (Next.js)
|
||||
|
||||
Une application web Node.js élégante pour afficher et partager vos liens de réseaux sociaux personnels.
|
||||
|
||||

|
||||
Une page de liens personnelle moderne construite avec Next.js (App Router).
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- 🎨 Interface moderne et responsive
|
||||
- 🔗 Affichage des liens vers vos réseaux sociaux
|
||||
- 📱 Compatible mobile et desktop
|
||||
- 🚀 Serveur Express.js léger
|
||||
- 🌟 Design avec gradient et animations
|
||||
- 📊 API REST pour récupérer les liens (extensibilité future)
|
||||
- 🎨 UI moderne et responsive
|
||||
- 🔎 Recherche instantanée dans les liens
|
||||
- 🏷️ Filtres par catégories
|
||||
- 🌗 Mode clair/sombre (persisté en local)
|
||||
- 🔐 Espace admin protégé (analytics privées)
|
||||
- ➕ Ajout et suppression de liens depuis le menu admin
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clonez le repository :
|
||||
```bash
|
||||
git clone https://github.com/arthur-pbty/links.git
|
||||
cd links
|
||||
```
|
||||
|
||||
2. Installez les dépendances :
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Personnalisez vos liens sociaux dans `app.js` (voir section Configuration)
|
||||
## Démarrage
|
||||
|
||||
4. Démarrez l'application :
|
||||
```bash
|
||||
npm start
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Ouvrez votre navigateur sur `http://localhost:3000`
|
||||
Puis ouvre http://localhost:3000
|
||||
|
||||
## Configuration
|
||||
## Scripts
|
||||
|
||||
Pour personnaliser vos liens sociaux, modifiez le tableau `socialLinks` dans le fichier `app.js` :
|
||||
- `npm run dev` : développement
|
||||
- `npm run build` : build de production
|
||||
- `npm start` : démarrage en production
|
||||
|
||||
```javascript
|
||||
const socialLinks = [
|
||||
{
|
||||
name: 'GitHub',
|
||||
url: 'https://github.com/votre-username',
|
||||
icon: '🐙',
|
||||
description: 'Mon profil GitHub'
|
||||
},
|
||||
// Ajoutez vos autres liens ici...
|
||||
];
|
||||
## Variables critiques (.env)
|
||||
|
||||
Copie `.env.example` vers `.env` et ajuste les valeurs :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Structure d'un lien
|
||||
Variables requises :
|
||||
|
||||
- `name` : Nom du réseau social
|
||||
- `url` : URL vers votre profil
|
||||
- `icon` : Emoji ou icône à afficher
|
||||
- `description` : Description courte du lien
|
||||
- `ADMIN_USERNAME`
|
||||
- `ADMIN_PASSWORD`
|
||||
- `ADMIN_SESSION_SECRET`
|
||||
- `PORT`
|
||||
|
||||
## Scripts disponibles
|
||||
## Configuration des données
|
||||
|
||||
- `npm start` : Démarre le serveur en mode production
|
||||
- `npm run dev` : Démarre le serveur en mode développement
|
||||
Modifie le fichier `config.js` :
|
||||
|
||||
## API
|
||||
- `profile` : nom, bio, avatar
|
||||
- `socialLinks` : liste de liens affichés
|
||||
|
||||
L'application expose également une API REST :
|
||||
Chaque lien peut contenir :
|
||||
|
||||
- `GET /` : Page principale avec l'interface web
|
||||
- `GET /api/links` : Récupère la liste des liens en JSON
|
||||
- `name`
|
||||
- `url`
|
||||
- `icon` (fichier dans `public/`)
|
||||
- `description`
|
||||
- `category`
|
||||
|
||||
## Technologies utilisées
|
||||
## Admin
|
||||
|
||||
- Node.js
|
||||
- Express.js
|
||||
- HTML5/CSS3
|
||||
- Design responsive
|
||||
- URL admin : http://localhost:3000/admin
|
||||
- Identifiant : admin
|
||||
- Mot de passe : 123456
|
||||
|
||||
## Déploiement
|
||||
Dans le menu admin, vous pouvez :
|
||||
|
||||
Pour déployer sur des plateformes comme Heroku, Vercel, ou Railway :
|
||||
- consulter les analytics (clics par lien)
|
||||
- ajouter un lien
|
||||
- supprimer un lien
|
||||
|
||||
1. Assurez-vous que la variable d'environnement `PORT` est configurée
|
||||
2. Le serveur écoutera automatiquement sur `process.env.PORT || 3000`
|
||||
## Docker Compose
|
||||
|
||||
Le `docker-compose.yml` lance maintenant :
|
||||
|
||||
1. `npm install`
|
||||
2. `npm run build`
|
||||
3. `npm start`
|
||||
|
||||
Test prod Docker :
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
MIT - Voir le fichier [LICENSE](LICENSE) pour plus de détails.
|
||||
MIT - Voir [LICENSE](LICENSE)
|
||||
@@ -1,70 +0,0 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
const app = express();
|
||||
const PORT = config.server.port;
|
||||
|
||||
// Configuration depuis le fichier config.js
|
||||
const { profile, socialLinks } = config;
|
||||
|
||||
// Middleware pour servir les fichiers statiques
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Route principale
|
||||
app.get('/', (req, res) => {
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mes Liens - Arthur</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="profile">
|
||||
<div class="avatar">
|
||||
<img src="${profile.avatar}" alt="Avatar" style="width:80px;height:80px;border-radius:50%;object-fit:cover;">
|
||||
</div>
|
||||
<h1>${profile.name}</h1>
|
||||
<p class="bio">${profile.bio}</p>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
${socialLinks.map(link => `
|
||||
<a href="${link.url}" target="_blank" rel="noopener noreferrer" class="link-card">
|
||||
<span class="icon">
|
||||
<img src="${link.icon}" alt="${link.name}" style="width:30px;height:30px;">
|
||||
</span>
|
||||
<div class="link-info">
|
||||
<h3>${link.name}</h3>
|
||||
<p>${link.description}</p>
|
||||
</div>
|
||||
<span class="arrow">→</span>
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2024 Arthur - Tous droits réservés</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
res.send(html);
|
||||
});
|
||||
|
||||
// Route API pour obtenir les liens (optionnel, pour future extensibilité)
|
||||
app.get('/api/links', (req, res) => {
|
||||
res.json({ links: socialLinks });
|
||||
});
|
||||
|
||||
// Démarrage du serveur
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Serveur démarré sur http://localhost:${PORT}`);
|
||||
console.log(`📱 Site de liens sociaux prêt !`);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import AdminPanel from '../../components/AdminPanel';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Admin - Mes Liens',
|
||||
description: 'Gestion des liens et analytics privées.'
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
return <AdminPanel />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import {
|
||||
ADMIN_COOKIE,
|
||||
createAdminSessionToken,
|
||||
isValidAdminCredentials
|
||||
} from '../../../../lib/auth';
|
||||
|
||||
export async function POST(request) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const username = body.username || '';
|
||||
const password = body.password || '';
|
||||
|
||||
if (!isValidAdminCredentials(username, password)) {
|
||||
return NextResponse.json({ error: 'Identifiants invalides.' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.set({
|
||||
name: ADMIN_COOKIE,
|
||||
value: createAdminSessionToken(),
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 12
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ADMIN_COOKIE } from '../../../../lib/auth';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.set({
|
||||
name: ADMIN_COOKIE,
|
||||
value: '',
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 0
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminRequest } from '../../../../lib/auth';
|
||||
|
||||
export async function GET(request) {
|
||||
return NextResponse.json({ authenticated: isAdminRequest(request) });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { incrementClick } from '../../../../lib/storage';
|
||||
|
||||
export async function POST(request) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const linkId = body.linkId;
|
||||
|
||||
if (!linkId) {
|
||||
return NextResponse.json({ error: 'linkId requis.' }, { status: 400 });
|
||||
}
|
||||
|
||||
await incrementClick(linkId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAnalyticsForLinks } from '../../../lib/storage';
|
||||
import { isAdminRequest } from '../../../lib/auth';
|
||||
|
||||
export async function GET(request) {
|
||||
if (!isAdminRequest(request)) {
|
||||
return NextResponse.json({ error: 'Non autorisé.' }, { status: 401 });
|
||||
}
|
||||
|
||||
const analytics = await getAnalyticsForLinks();
|
||||
return NextResponse.json({ analytics });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { removeLink } from '../../../../lib/storage';
|
||||
import { isAdminRequest } from '../../../../lib/auth';
|
||||
|
||||
export async function DELETE(request, { params }) {
|
||||
if (!isAdminRequest(request)) {
|
||||
return NextResponse.json({ error: 'Non autorisé.' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const deleted = await removeLink(id);
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ error: 'Lien introuvable.' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { addLink, getLinks } from '../../../lib/storage';
|
||||
import { isAdminRequest } from '../../../lib/auth';
|
||||
|
||||
export async function GET() {
|
||||
const links = await getLinks();
|
||||
return NextResponse.json({ links });
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
if (!isAdminRequest(request)) {
|
||||
return NextResponse.json({ error: 'Non autorisé.' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const required = ['name', 'url'];
|
||||
const missing = required.some((field) => !payload[field]);
|
||||
|
||||
if (missing) {
|
||||
return NextResponse.json({ error: 'Nom et URL sont obligatoires.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const created = await addLink(payload);
|
||||
return NextResponse.json({ link: created }, { status: 201 });
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
:root {
|
||||
--bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--surface: rgba(255, 255, 255, 0.95);
|
||||
--text: #1f2937;
|
||||
--muted: #6b7280;
|
||||
--card: #ffffff;
|
||||
--accent: #4f46e5;
|
||||
--border: rgba(255, 255, 255, 0.2);
|
||||
--chip: #eef2ff;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--bg: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
--surface: rgba(15, 23, 42, 0.92);
|
||||
--text: #e5e7eb;
|
||||
--muted: #94a3b8;
|
||||
--card: rgba(30, 41, 59, 0.9);
|
||||
--accent: #818cf8;
|
||||
--border: rgba(148, 163, 184, 0.2);
|
||||
--chip: rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile {
|
||||
text-align: center;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
margin-bottom: 10px;
|
||||
border: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.profile h1 {
|
||||
margin: 0;
|
||||
font-size: 1.9rem;
|
||||
}
|
||||
|
||||
.bio {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid transparent;
|
||||
background: var(--chip);
|
||||
color: var(--text);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.link-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.link-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.link-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
margin: 0;
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.link-description {
|
||||
margin: 3px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.link-meta {
|
||||
text-align: right;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 14px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.admin-container {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.admin-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-title {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.admin-subtitle {
|
||||
margin: 0 0 10px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.admin-count {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
border: 1px solid var(--accent);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-error {
|
||||
margin-top: 14px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.profile h1 {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import './globals.css';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Mes Liens - Arthur',
|
||||
description: 'Tous mes liens sociaux et projets au même endroit.'
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="fr" suppressHydrationWarning>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import LinksPage from '../components/LinksPage';
|
||||
import { profile } from '../config';
|
||||
|
||||
export default function Page() {
|
||||
return <LinksPage profile={profile} />;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const initialForm = {
|
||||
name: '',
|
||||
url: '',
|
||||
icon: '/github.png',
|
||||
description: '',
|
||||
category: 'Autres'
|
||||
};
|
||||
|
||||
export default function AdminPanel() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('');
|
||||
const [links, setLinks] = useState([]);
|
||||
const [analytics, setAnalytics] = useState([]);
|
||||
const [form, setForm] = useState(initialForm);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function loadAdminData() {
|
||||
const [linksResponse, analyticsResponse] = await Promise.all([
|
||||
fetch('/api/links'),
|
||||
fetch('/api/analytics')
|
||||
]);
|
||||
|
||||
if (!linksResponse.ok || !analyticsResponse.ok) {
|
||||
setError('Impossible de charger les données admin.');
|
||||
return;
|
||||
}
|
||||
|
||||
const linksData = await linksResponse.json();
|
||||
const analyticsData = await analyticsResponse.json();
|
||||
|
||||
setLinks(linksData.links || []);
|
||||
setAnalytics(analyticsData.analytics || []);
|
||||
setError('');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function checkSession() {
|
||||
const response = await fetch('/api/admin/session');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
setIsAuthenticated(true);
|
||||
await loadAdminData();
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
|
||||
const response = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setError('Identifiant ou mot de passe invalide.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthenticated(true);
|
||||
setPassword('');
|
||||
await loadAdminData();
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/admin/logout', { method: 'POST' });
|
||||
setIsAuthenticated(false);
|
||||
setLinks([]);
|
||||
setAnalytics([]);
|
||||
}
|
||||
|
||||
async function handleAddLink(event) {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
|
||||
const response = await fetch('/api/links', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setError("Impossible d'ajouter le lien.");
|
||||
return;
|
||||
}
|
||||
|
||||
setForm(initialForm);
|
||||
await loadAdminData();
|
||||
}
|
||||
|
||||
async function handleDeleteLink(linkId) {
|
||||
setError('');
|
||||
const response = await fetch(`/api/links/${linkId}`, { method: 'DELETE' });
|
||||
|
||||
if (!response.ok) {
|
||||
setError('Impossible de supprimer ce lien.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
await loadAdminData();
|
||||
}
|
||||
|
||||
const totalClicks = useMemo(
|
||||
() => analytics.reduce((sum, item) => sum + (item.clicks || 0), 0),
|
||||
[analytics]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="container admin-container">
|
||||
<p className="empty">Chargement...</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="container admin-container">
|
||||
<h1 className="admin-title">Admin</h1>
|
||||
<p className="bio">Accès protégé aux analytics et à la gestion des liens.</p>
|
||||
|
||||
<form className="admin-form" onSubmit={handleLogin}>
|
||||
<input
|
||||
className="search"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Identifiant"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="search"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Mot de passe"
|
||||
required
|
||||
/>
|
||||
<button className="theme-toggle" type="submit">
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error ? <p className="admin-error">{error}</p> : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="container admin-container">
|
||||
<div className="admin-head">
|
||||
<h1 className="admin-title">Menu Admin</h1>
|
||||
<button className="theme-toggle" type="button" onClick={handleLogout}>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2 className="admin-subtitle">Analytics</h2>
|
||||
<p className="bio">Total clics: {totalClicks}</p>
|
||||
<div className="admin-list">
|
||||
{analytics.map((item) => (
|
||||
<div key={item.id} className="admin-row">
|
||||
<div>
|
||||
<strong>{item.name}</strong>
|
||||
<div className="link-description">{item.url}</div>
|
||||
</div>
|
||||
<div className="admin-count">{item.clicks} clics</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2 className="admin-subtitle">Ajouter un lien</h2>
|
||||
<form className="admin-form" onSubmit={handleAddLink}>
|
||||
<input
|
||||
className="search"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
placeholder="Nom"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="search"
|
||||
name="url"
|
||||
value={form.url}
|
||||
onChange={(event) => setForm({ ...form, url: event.target.value })}
|
||||
placeholder="URL"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="search"
|
||||
name="icon"
|
||||
value={form.icon}
|
||||
onChange={(event) => setForm({ ...form, icon: event.target.value })}
|
||||
placeholder="Icône (ex: /github.png)"
|
||||
/>
|
||||
<input
|
||||
className="search"
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
||||
placeholder="Description"
|
||||
/>
|
||||
<input
|
||||
className="search"
|
||||
name="category"
|
||||
value={form.category}
|
||||
onChange={(event) => setForm({ ...form, category: event.target.value })}
|
||||
placeholder="Catégorie"
|
||||
/>
|
||||
<button className="theme-toggle" type="submit">
|
||||
Ajouter
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2 className="admin-subtitle">Supprimer un lien</h2>
|
||||
<div className="admin-list">
|
||||
{links.map((link) => (
|
||||
<div key={link.id} className="admin-row">
|
||||
<div>
|
||||
<strong>{link.name}</strong>
|
||||
<div className="link-description">{link.url}</div>
|
||||
</div>
|
||||
<button
|
||||
className="danger-btn"
|
||||
type="button"
|
||||
onClick={() => handleDeleteLink(link.id)}
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? <p className="admin-error">{error}</p> : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
function normalize(value) {
|
||||
return (value || '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
function getDomain(url) {
|
||||
try {
|
||||
return new URL(url).hostname.replace('www.', '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export default function LinksPage({ profile, socialLinks }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState('Tous');
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [links, setLinks] = useState(socialLinks || []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(storedTheme);
|
||||
document.documentElement.setAttribute('data-theme', storedTheme);
|
||||
|
||||
async function loadLinks() {
|
||||
const response = await fetch('/api/links');
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setLinks(data.links || []);
|
||||
}
|
||||
|
||||
loadLinks();
|
||||
}, []);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set((links || []).map((link) => link.category || 'Autres'));
|
||||
return ['Tous', ...Array.from(set)];
|
||||
}, [links]);
|
||||
|
||||
const filteredLinks = useMemo(() => {
|
||||
return (links || []).filter((link) => {
|
||||
const matchesCategory =
|
||||
activeCategory === 'Tous' || (link.category || 'Autres') === activeCategory;
|
||||
const target = normalize(`${link.name} ${link.description} ${link.url}`);
|
||||
const matchesSearch = target.includes(normalize(query));
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
}, [links, activeCategory, query]);
|
||||
|
||||
function toggleTheme() {
|
||||
const nextTheme = theme === 'light' ? 'dark' : 'light';
|
||||
setTheme(nextTheme);
|
||||
localStorage.setItem('theme', nextTheme);
|
||||
document.documentElement.setAttribute('data-theme', nextTheme);
|
||||
}
|
||||
|
||||
async function trackClick(linkId) {
|
||||
await fetch('/api/analytics/click', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ linkId })
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="container">
|
||||
<div className="header">
|
||||
<button type="button" className="theme-toggle" onClick={toggleTheme}>
|
||||
{theme === 'light' ? '🌙 Sombre' : '☀️ Clair'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="profile">
|
||||
<img src={profile.avatar} alt="Avatar" className="avatar" />
|
||||
<h1>{profile.name}</h1>
|
||||
<p className="bio">{profile.bio}</p>
|
||||
</section>
|
||||
|
||||
<section className="controls">
|
||||
<input
|
||||
className="search"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Rechercher un lien..."
|
||||
aria-label="Rechercher un lien"
|
||||
/>
|
||||
|
||||
<div className="categories">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
className={`chip ${activeCategory === category ? 'active' : ''}`}
|
||||
onClick={() => setActiveCategory(category)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="links">
|
||||
{filteredLinks.length === 0 ? (
|
||||
<p className="empty">Aucun lien ne correspond à ta recherche.</p>
|
||||
) : (
|
||||
filteredLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link-card"
|
||||
onClick={() => trackClick(link.id)}
|
||||
>
|
||||
<img src={link.icon} alt={link.name} className="icon" />
|
||||
<div className="link-info">
|
||||
<p className="link-title">{link.name}</p>
|
||||
<p className="link-description">{link.description}</p>
|
||||
</div>
|
||||
<div className="link-meta">
|
||||
<div>{getDomain(link.url)}</div>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
© {new Date().getFullYear()} {profile.name} - Tous droits réservés ·{' '}
|
||||
<a href="/admin" className="admin-link">
|
||||
Admin
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +1,59 @@
|
||||
// Configuration pour vos liens de réseaux sociaux
|
||||
// Modifiez ce fichier pour personnaliser vos liens
|
||||
// Configuration des données affichées dans l'application Next.js
|
||||
|
||||
module.exports = {
|
||||
// Informations du profil
|
||||
profile: {
|
||||
export const profile = {
|
||||
name: 'Arthur',
|
||||
bio: 'Développeur passionné | Sportif de haut niveau',
|
||||
avatar: '/avatar.png' // Chemin relatif vers l'image dans public/
|
||||
},
|
||||
avatar: '/avatar.png'
|
||||
};
|
||||
|
||||
// Liste des liens sociaux
|
||||
socialLinks: [
|
||||
export const socialLinks = [
|
||||
{
|
||||
name: 'GitHub',
|
||||
url: 'https://github.com/arthur-pbty',
|
||||
icon: '/github.png', // Chemin relatif vers l'image dans public/
|
||||
description: 'Mon profil GitHub'
|
||||
icon: '/github.png',
|
||||
description: 'Mon profil GitHub',
|
||||
category: 'Développement'
|
||||
},
|
||||
{
|
||||
name: 'Instagram',
|
||||
url: 'https://www.instagram.com/arthur.pbty/',
|
||||
icon: '/instagram.png',
|
||||
description: 'Mes photos et stories'
|
||||
description: 'Mes photos et stories',
|
||||
category: 'Réseaux'
|
||||
},
|
||||
{
|
||||
name: 'FFVoile',
|
||||
url: 'https://www.ffvoile.fr/ffv/sportif/cif/cif_detail.aspx?NoLicence=1443697B',
|
||||
icon: '/ffvoile.png',
|
||||
description: 'Mon profil FFVoile'
|
||||
description: 'Mon profil FFVoile',
|
||||
category: 'Sport'
|
||||
},
|
||||
{
|
||||
name: 'YouTube',
|
||||
url: 'https://www.youtube.com/@arthur-pbty',
|
||||
icon: '/youtube.png',
|
||||
description: 'Ma chaîne YouTube'
|
||||
description: 'Ma chaîne YouTube',
|
||||
category: 'Création'
|
||||
},
|
||||
{
|
||||
name: 'Discord',
|
||||
url: 'https://discord.gg/MZSseRvCk8',
|
||||
icon: '/discord.png',
|
||||
description: 'Rejoignez mon serveur Discord'
|
||||
description: 'Rejoignez mon serveur Discord',
|
||||
category: 'Communauté'
|
||||
},
|
||||
{
|
||||
name: 'TikTok',
|
||||
url: 'https://www.tiktok.com/@arthur.pbty',
|
||||
icon: '/tiktok.png',
|
||||
description: 'Mes vidéos TikTok'
|
||||
description: 'Mes vidéos TikTok',
|
||||
category: 'Création'
|
||||
},
|
||||
{
|
||||
name: 'Twitch',
|
||||
url: 'https://www.twitch.tv/arthur_pbty',
|
||||
icon: '/twitch.png',
|
||||
description: 'Mes streams en direct'
|
||||
description: 'Mes streams en direct',
|
||||
category: 'Streaming'
|
||||
}
|
||||
],
|
||||
|
||||
// Configuration du serveur
|
||||
server: {
|
||||
port: process.env.PORT || 3000
|
||||
}
|
||||
};
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"github-1": 1
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
[
|
||||
{
|
||||
"id": "github-1",
|
||||
"name": "GitHub",
|
||||
"url": "https://github.com/arthur-pbty",
|
||||
"icon": "/github.png",
|
||||
"description": "Mon profil GitHub",
|
||||
"category": "Développement"
|
||||
},
|
||||
{
|
||||
"id": "instagram-2",
|
||||
"name": "Instagram",
|
||||
"url": "https://www.instagram.com/arthur.pbty/",
|
||||
"icon": "/instagram.png",
|
||||
"description": "Mes photos et stories",
|
||||
"category": "Réseaux"
|
||||
},
|
||||
{
|
||||
"id": "ffvoile-3",
|
||||
"name": "FFVoile",
|
||||
"url": "https://www.ffvoile.fr/ffv/sportif/cif/cif_detail.aspx?NoLicence=1443697B",
|
||||
"icon": "/ffvoile.png",
|
||||
"description": "Mon profil FFVoile",
|
||||
"category": "Sport"
|
||||
},
|
||||
{
|
||||
"id": "youtube-4",
|
||||
"name": "YouTube",
|
||||
"url": "https://www.youtube.com/@arthur-pbty",
|
||||
"icon": "/youtube.png",
|
||||
"description": "Ma chaîne YouTube",
|
||||
"category": "Création"
|
||||
},
|
||||
{
|
||||
"id": "discord-5",
|
||||
"name": "Discord",
|
||||
"url": "https://discord.gg/MZSseRvCk8",
|
||||
"icon": "/discord.png",
|
||||
"description": "Rejoignez mon serveur Discord",
|
||||
"category": "Communauté"
|
||||
},
|
||||
{
|
||||
"id": "tiktok-6",
|
||||
"name": "TikTok",
|
||||
"url": "https://www.tiktok.com/@arthur.pbty",
|
||||
"icon": "/tiktok.png",
|
||||
"description": "Mes vidéos TikTok",
|
||||
"category": "Création"
|
||||
},
|
||||
{
|
||||
"id": "twitch-7",
|
||||
"name": "Twitch",
|
||||
"url": "https://www.twitch.tv/arthur_pbty",
|
||||
"icon": "/twitch.png",
|
||||
"description": "Mes streams en direct",
|
||||
"category": "Streaming"
|
||||
}
|
||||
]
|
||||
+6
-6
@@ -1,8 +1,6 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: node:18
|
||||
image: node:20
|
||||
working_dir: /usr/src/app
|
||||
volumes:
|
||||
- ./:/usr/src/app
|
||||
@@ -10,6 +8,8 @@ services:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
command: sh -c "npm install && npm start"
|
||||
- PORT=${PORT:-3000}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
||||
command: sh -c "npm install && npm run build && npm start"
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
function getRequiredEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const ADMIN_USERNAME = getRequiredEnv('ADMIN_USERNAME');
|
||||
export const ADMIN_PASSWORD = getRequiredEnv('ADMIN_PASSWORD');
|
||||
export const ADMIN_SESSION_SECRET = getRequiredEnv('ADMIN_SESSION_SECRET');
|
||||
export const ADMIN_COOKIE = 'admin_session';
|
||||
|
||||
function hash(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function expectedToken() {
|
||||
return hash(`${ADMIN_USERNAME}:${ADMIN_PASSWORD}:${ADMIN_SESSION_SECRET}`);
|
||||
}
|
||||
|
||||
export function isValidAdminCredentials(username, password) {
|
||||
return username === ADMIN_USERNAME && password === ADMIN_PASSWORD;
|
||||
}
|
||||
|
||||
export function createAdminSessionToken() {
|
||||
return expectedToken();
|
||||
}
|
||||
|
||||
export function isAdminRequest(request) {
|
||||
const token = request.cookies.get(ADMIN_COOKIE)?.value;
|
||||
return token === expectedToken();
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { socialLinks } from '../config';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const LINKS_FILE = path.join(DATA_DIR, 'links.json');
|
||||
const ANALYTICS_FILE = path.join(DATA_DIR, 'analytics.json');
|
||||
|
||||
function slugify(text) {
|
||||
return (text || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function withId(link, index) {
|
||||
return {
|
||||
id: link.id || `${slugify(link.name)}-${index + 1}`,
|
||||
name: link.name,
|
||||
url: link.url,
|
||||
icon: link.icon,
|
||||
description: link.description || '',
|
||||
category: link.category || 'Autres'
|
||||
};
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
try {
|
||||
await readFile(filePath, 'utf-8');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(filePath, fallback) {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value) {
|
||||
await writeFile(filePath, JSON.stringify(value, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function ensureDataFiles() {
|
||||
await mkdir(DATA_DIR, { recursive: true });
|
||||
|
||||
if (!(await fileExists(LINKS_FILE))) {
|
||||
const initialLinks = socialLinks.map(withId);
|
||||
await writeJson(LINKS_FILE, initialLinks);
|
||||
}
|
||||
|
||||
if (!(await fileExists(ANALYTICS_FILE))) {
|
||||
await writeJson(ANALYTICS_FILE, {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLinks() {
|
||||
await ensureDataFiles();
|
||||
return readJson(LINKS_FILE, []);
|
||||
}
|
||||
|
||||
export async function addLink(payload) {
|
||||
await ensureDataFiles();
|
||||
const links = await getLinks();
|
||||
|
||||
const newLink = {
|
||||
id: randomUUID(),
|
||||
name: payload.name,
|
||||
url: payload.url,
|
||||
icon: payload.icon || '/github.png',
|
||||
description: payload.description || '',
|
||||
category: payload.category || 'Autres'
|
||||
};
|
||||
|
||||
const updatedLinks = [...links, newLink];
|
||||
await writeJson(LINKS_FILE, updatedLinks);
|
||||
return newLink;
|
||||
}
|
||||
|
||||
export async function removeLink(linkId) {
|
||||
await ensureDataFiles();
|
||||
const links = await getLinks();
|
||||
const nextLinks = links.filter((link) => link.id !== linkId);
|
||||
|
||||
if (nextLinks.length === links.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await writeJson(LINKS_FILE, nextLinks);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function incrementClick(linkId) {
|
||||
await ensureDataFiles();
|
||||
const analytics = await readJson(ANALYTICS_FILE, {});
|
||||
analytics[linkId] = (analytics[linkId] || 0) + 1;
|
||||
await writeJson(ANALYTICS_FILE, analytics);
|
||||
}
|
||||
|
||||
export async function getAnalyticsForLinks() {
|
||||
await ensureDataFiles();
|
||||
const [links, analytics] = await Promise.all([
|
||||
readJson(LINKS_FILE, []),
|
||||
readJson(ANALYTICS_FILE, {})
|
||||
]);
|
||||
|
||||
return links.map((link) => ({
|
||||
id: link.id,
|
||||
name: link.name,
|
||||
url: link.url,
|
||||
clicks: analytics[link.id] || 0
|
||||
}));
|
||||
}
|
||||
Generated
+819
-739
File diff suppressed because it is too large
Load Diff
+8
-5
@@ -1,16 +1,19 @@
|
||||
{
|
||||
"name": "links",
|
||||
"version": "1.0.0",
|
||||
"description": "A web app to distribute personal social media links",
|
||||
"main": "app.js",
|
||||
"description": "Une page de liens moderne avec Next.js",
|
||||
"main": "next.config.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"dev": "node app.js"
|
||||
"dev": "node ./node_modules/next/dist/bin/next dev",
|
||||
"build": "node ./node_modules/next/dist/bin/next build",
|
||||
"start": "node ./node_modules/next/dist/bin/next start"
|
||||
},
|
||||
"keywords": ["links", "social", "profile"],
|
||||
"author": "arthur-pbty",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -1,149 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.profile {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.profile h1 {
|
||||
font-size: 2rem;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bio {
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.link-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 15px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.link-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.5rem;
|
||||
margin-right: 15px;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.link-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.link-info h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.link-info p {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 1.2rem;
|
||||
color: #667eea;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.link-card:hover .arrow {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 30px 20px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.profile h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.link-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.3rem;
|
||||
margin-right: 12px;
|
||||
min-width: 25px;
|
||||
}
|
||||
|
||||
.link-info h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.link-info p {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user