Refactor code structure and remove redundant sections for improved readability and maintainability

This commit is contained in:
Puechberty Arthur
2026-02-23 03:57:05 +01:00
parent 5be8966d66
commit 6760a523e4
27 changed files with 2025 additions and 1087 deletions
+4
View File
@@ -0,0 +1,4 @@
ADMIN_USERNAME=admin
ADMIN_PASSWORD=123456
ADMIN_SESSION_SECRET=change-me-in-production
PORT=3000
+11
View File
@@ -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
}
+59 -55
View File
@@ -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).
![Social Links Website](https://github.com/user-attachments/assets/61f94cae-166c-4dc0-ad17-2412e184d4ad)
## Fonctionnalités ## Fonctionnalités
- 🎨 Interface moderne et responsive - 🎨 UI moderne et responsive
- 🔗 Affichage des liens vers vos réseaux sociaux - 🔎 Recherche instantanée dans les liens
- 📱 Compatible mobile et desktop - 🏷️ Filtres par catégories
- 🚀 Serveur Express.js léger - 🌗 Mode clair/sombre (persisté en local)
- 🌟 Design avec gradient et animations - 🔐 Espace admin protégé (analytics privées)
- 📊 API REST pour récupérer les liens (extensibilité future) - Ajout et suppression de liens depuis le menu admin
## Installation ## Installation
1. Clonez le repository :
```bash
git clone https://github.com/arthur-pbty/links.git
cd links
```
2. Installez les dépendances :
```bash ```bash
npm install npm install
``` ```
3. Personnalisez vos liens sociaux dans `app.js` (voir section Configuration) ## Démarrage
4. Démarrez l'application :
```bash ```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 ## Variables critiques (.env)
const socialLinks = [
{ Copie `.env.example` vers `.env` et ajuste les valeurs :
name: 'GitHub',
url: 'https://github.com/votre-username', ```bash
icon: '🐙', cp .env.example .env
description: 'Mon profil GitHub'
},
// Ajoutez vos autres liens ici...
];
``` ```
### Structure d'un lien Variables requises :
- `name` : Nom du réseau social - `ADMIN_USERNAME`
- `url` : URL vers votre profil - `ADMIN_PASSWORD`
- `icon` : Emoji ou icône à afficher - `ADMIN_SESSION_SECRET`
- `description` : Description courte du lien - `PORT`
## Scripts disponibles ## Configuration des données
- `npm start` : Démarre le serveur en mode production Modifie le fichier `config.js` :
- `npm run dev` : Démarre le serveur en mode développement
## 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 - `name`
- `GET /api/links` : Récupère la liste des liens en JSON - `url`
- `icon` (fichier dans `public/`)
- `description`
- `category`
## Technologies utilisées ## Admin
- Node.js - URL admin : http://localhost:3000/admin
- Express.js - Identifiant : admin
- HTML5/CSS3 - Mot de passe : 123456
- Design responsive
## 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 ## Docker Compose
2. Le serveur écoutera automatiquement sur `process.env.PORT || 3000`
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 ## Licence
MIT - Voir le fichier [LICENSE](LICENSE) pour plus de détails. MIT - Voir [LICENSE](LICENSE)
-70
View File
@@ -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 !`);
});
+10
View File
@@ -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 />;
}
+29
View File
@@ -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;
}
+15
View File
@@ -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;
}
+6
View File
@@ -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) });
}
+14
View File
@@ -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 });
}
+12
View File
@@ -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 });
}
+17
View File
@@ -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 });
}
+25
View File
@@ -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
View File
@@ -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;
}
}
+14
View File
@@ -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>
);
}
+6
View File
@@ -0,0 +1,6 @@
import LinksPage from '../components/LinksPage';
import { profile } from '../config';
export default function Page() {
return <LinksPage profile={profile} />;
}
+267
View File
@@ -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>
);
}
+144
View File
@@ -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>
);
}
+57 -60
View File
@@ -1,62 +1,59 @@
// Configuration pour vos liens de réseaux sociaux // Configuration des données affichées dans l'application Next.js
// Modifiez ce fichier pour personnaliser vos liens
module.exports = { export const profile = {
// Informations du profil name: 'Arthur',
profile: { bio: 'Développeur passionné | Sportif de haut niveau',
name: 'Arthur', avatar: '/avatar.png'
bio: 'Développeur passionné | Sportif de haut niveau',
avatar: '/avatar.png' // Chemin relatif vers l'image dans public/
},
// Liste des liens sociaux
socialLinks: [
{
name: 'GitHub',
url: 'https://github.com/arthur-pbty',
icon: '/github.png', // Chemin relatif vers l'image dans public/
description: 'Mon profil GitHub'
},
{
name: 'Instagram',
url: 'https://www.instagram.com/arthur.pbty/',
icon: '/instagram.png',
description: 'Mes photos et stories'
},
{
name: 'FFVoile',
url: 'https://www.ffvoile.fr/ffv/sportif/cif/cif_detail.aspx?NoLicence=1443697B',
icon: '/ffvoile.png',
description: 'Mon profil FFVoile'
},
{
name: 'YouTube',
url: 'https://www.youtube.com/@arthur-pbty',
icon: '/youtube.png',
description: 'Ma chaîne YouTube'
},
{
name: 'Discord',
url: 'https://discord.gg/MZSseRvCk8',
icon: '/discord.png',
description: 'Rejoignez mon serveur Discord'
},
{
name: 'TikTok',
url: 'https://www.tiktok.com/@arthur.pbty',
icon: '/tiktok.png',
description: 'Mes vidéos TikTok'
},
{
name: 'Twitch',
url: 'https://www.twitch.tv/arthur_pbty',
icon: '/twitch.png',
description: 'Mes streams en direct'
}
],
// Configuration du serveur
server: {
port: process.env.PORT || 3000
}
}; };
export const socialLinks = [
{
name: 'GitHub',
url: 'https://github.com/arthur-pbty',
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',
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',
category: 'Sport'
},
{
name: 'YouTube',
url: 'https://www.youtube.com/@arthur-pbty',
icon: '/youtube.png',
description: 'Ma chaîne YouTube',
category: 'Création'
},
{
name: 'Discord',
url: 'https://discord.gg/MZSseRvCk8',
icon: '/discord.png',
description: 'Rejoignez mon serveur Discord',
category: 'Communauté'
},
{
name: 'TikTok',
url: 'https://www.tiktok.com/@arthur.pbty',
icon: '/tiktok.png',
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',
category: 'Streaming'
}
];
+3
View File
@@ -0,0 +1,3 @@
{
"github-1": 1
}
+58
View File
@@ -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
View File
@@ -1,8 +1,6 @@
version: '3.8'
services: services:
app: app:
image: node:18 image: node:20
working_dir: /usr/src/app working_dir: /usr/src/app
volumes: volumes:
- ./:/usr/src/app - ./:/usr/src/app
@@ -10,6 +8,8 @@ services:
- "3000:3000" - "3000:3000"
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=production - PORT=${PORT:-3000}
- PORT=3000 - ADMIN_USERNAME=${ADMIN_USERNAME}
command: sh -c "npm install && npm start" - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
command: sh -c "npm install && npm run build && npm start"
+35
View File
@@ -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
View File
@@ -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
}));
}
+823 -743
View File
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -1,16 +1,19 @@
{ {
"name": "links", "name": "links",
"version": "1.0.0", "version": "1.0.0",
"description": "A web app to distribute personal social media links", "description": "Une page de liens moderne avec Next.js",
"main": "app.js", "main": "next.config.js",
"scripts": { "scripts": {
"start": "node app.js", "dev": "node ./node_modules/next/dist/bin/next dev",
"dev": "node app.js" "build": "node ./node_modules/next/dist/bin/next build",
"start": "node ./node_modules/next/dist/bin/next start"
}, },
"keywords": ["links", "social", "profile"], "keywords": ["links", "social", "profile"],
"author": "arthur-pbty", "author": "arthur-pbty",
"license": "MIT", "license": "MIT",
"dependencies": { "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

-149
View File
@@ -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;
}
}