Template de portfolio étudiant

Un portfolio Next.js 15 pour étudiants. Son administration, protégée par JWT, gère profil, CV, diplômes, projets et documents rangés en dossiers, avec PostgreSQL pour les données.

Présentation

J'ai construit ce portfolio pour présenter mon parcours en cybersécurité, puis je l'ai publié comme modèle pour d'autres étudiants. Le site public, en Next.js 15 (App Router), TypeScript et Tailwind CSS, présente le profil, les compétences et les projets avec des animations Framer Motion, et s'adapte à tous les écrans. L'administration, sur /admin, gère profil, CV, diplômes, projets, catégories, technologies et un espace de documents rangé en dossiers. Les données vivent dans PostgreSQL, interrogé avec pg sans ORM, sur douze tables créées par initDatabase. Je l'ai développé en binôme avec Claude.

L'accueil de l'administration : le menu mène aux rubriques gérées, et les statistiques affichées sont des valeurs fictives écrites dans AdvancedDashboard.

Un explorateur de fichiers dans l'administration

Je voulais un espace de documents qui se manipule comme un cloud personnel. Les dossiers sont stockés dans document_folders, où parent_id référence le parent en ON DELETE CASCADE. Le fil d'Ariane se construit dans getBreadcrumbPath, en remontant les parent_id jusqu'à la racine.

DocumentManager.tsx regroupe la navigation, le menu contextuel, le glisser-déposer et la sélection multiple. Dans la zone de dépôt, un compteur dragCounter suit dragenter et dragleave, pour que la surbrillance ne clignote pas au-dessus d'un élément enfant. La sélection est un Set d'identifiants, et la suppression de masse envoie une requête DELETE par élément.

Chaque fichier passe par /api/documents/files, qui l'écrit dans public/mes-documents/ sous un nom doc_<timestamp>.<extension> et enregistre le nom d'origine, la taille, le type MIME et le dossier. Dans le navigateur, un cache garde chaque liste cinq minutes (CACHE_DURATION), et invalidateCache en efface les clés concernées après chaque écriture.

Une suppression refusée jusque dans le SQL

« Mes CV » et « Mes Diplômes » ne doivent pas disparaître par erreur : scripts/add-secure-folders.cjs les crée avec is_native et is_secure à vrai, et un document peut porter is_protected. Le menu contextuel masque renommer et supprimer sur un dossier sécurisé ou un document protégé. Mais un bouton masqué se contourne avec une simple requête, alors la suppression est aussi filtrée dans le SQL :

export async function deleteDocumentFolder(id: number): Promise<void> {
  await query('DELETE FROM document_folders WHERE id = $1 AND is_native = FALSE', [id])
}
 
export async function deleteDocument(id: number): Promise<void> {
  await query('DELETE FROM documents WHERE id = $1 AND is_protected = FALSE', [id])
}

/api/auth/login compare le mot de passe à son empreinte bcrypt (bcryptjs, coût 10) et renvoie un JWT valable 24 heures, signé dans lib/auth.ts. Le navigateur le garde dans localStorage et l'envoie dans l'en-tête Authorization. Chaque route d'administration qui écrit appelle verifyToken avant d'agir. Hors statistiques (/api/analytics), les routes de lecture restent publiques : le site en a besoin pour le profil et les projets.

Des diplômes qui se rangent dans leurs dossiers

Un diplôme a ses métadonnées dans diplomas et ses fichiers dans diploma_files. À la création ou à la modification, syncDiplomaToDocuments crée sous « Mes Diplômes » un dossier [Titre] - [date], puis ajoute dans documents une ligne par fichier. Le fichier n'est pas copié : la ligne pointe vers le même file_path. Avant chaque insertion, le dossier est cherché par nom et parent, le document par filename et dossier. Le bouton « Sync Diplômes » (/api/documents/sync-diplomas) peut donc être relancé sans créer de doublons.

Ce bouton sert vraiment : à la création, le diplôme est enregistré avant l'envoi de ses fichiers, et l'upload ne relance pas la synchronisation. Le dossier naît vide, et les fichiers n'y arrivent qu'à la modification suivante ou par ce bouton.

Pour le CV, je garde toutes les versions dans cv_files, et une seule porte is_current. setCurrentCV change la version active et met à jour le profil dans la même transaction.

Une vitrine branchée sur la même base

createProject et updateProject écrivent le projet et ses liens project_technologies dans une transaction, avec ROLLBACK en cas d'erreur. /api/featured limite les projets mis en avant à quatre, ordonnés par la colonne position que updateFeaturedProjects réécrit à chaque changement.

L'accueil lit ces données à l'exécution : ProjectsPreviewDynamic interroge /api/projects, ProfileContext charge /api/profile. Sur toutes les pages, BackgroundAnimation anime sur un canvas cinquante particules, reliées par un trait sous 100 pixels d'écart.

Le haut de l'accueil public : le titre et les liens viennent du profil chargé par ProfileContext, et les points du fond sont les particules de BackgroundAnimation.

Ce que le README promettait et que le code ne tenait pas

Le README promet une validation des types et une vérification des fichiers envoyés. Dans les faits :

  • Les formats ne sont filtrés que par l'attribut accept de l'<input>. Un fichier glissé-déposé ou envoyé directement à l'API passe sans contrôle. Seule la route du CV vérifie le type et la taille : un PDF de 50 Mo au plus.
  • « Protégé » protège de la suppression, pas de la modification ni de la lecture. updateDocumentFolder renomme aussi un dossier natif, et updateDocument accepte de remettre is_protected à faux. Les fichiers sont dans public/, servis tels quels, et la liste des documents se lit sans jeton. Je servirais aujourd'hui les diplômes depuis un dossier privé, par une route authentifiée.
  • Tout n'est pas branché. /projets affiche des projets d'exemple écrits dans la page, les compétences de l'accueil sont fixées dans SkillsSection, le formulaire de contact simule l'envoi avec un setTimeout, et AdvancedDashboard montre des statistiques fictives.
  • La synchronisation des diplômes existe en trois copies, chacune avec son Pool, et le schéma a évolué par scripts correctifs (fix-secure-folders.cjs), pas par migrations.

Je retiens qu'un contrôle d'accès se vérifie là où les fichiers sont servis, pas dans le menu qui les affiche.