
Overview
I built this portfolio to present my background in cybersecurity, then published it as a template for other students. The public site, in Next.js 15 (App Router), TypeScript and Tailwind CSS, presents the profile, skills and projects with Framer Motion animations, and adapts to every screen. The admin area, at /admin, manages the profile, CV, diplomas, projects, categories, technologies and a document space organised in folders. The data lives in PostgreSQL, queried with pg without an ORM, across twelve tables created by initDatabase. I developed it in tandem with Claude.
The admin home page: the menu leads to the managed sections, and the statistics shown are mock values written into AdvancedDashboard.
A file explorer inside the admin area
I wanted a document space that handles like a personal cloud. Folders are stored in document_folders, where parent_id references the parent with ON DELETE CASCADE. The breadcrumb is built in getBreadcrumbPath, by walking up the parent_id chain to the root.
DocumentManager.tsx brings together navigation, the context menu, drag and drop and multiple selection. In the drop zone, a dragCounter tracks dragenter and dragleave, so the highlight does not flicker over a child element. The selection is a Set of identifiers, and bulk delete sends one DELETE request per item.
Each file goes through /api/documents/files, which writes it to public/mes-documents/ under a doc_<timestamp>.<extension> name and records the original name, size, MIME type and folder. In the browser, a cache keeps each list for five minutes (CACHE_DURATION), and invalidateCache clears the affected keys after every write.
Deletion refused all the way down to the SQL
"Mes CV" and "Mes Diplômes" must not disappear by mistake: scripts/add-secure-folders.cjs creates them with is_native and is_secure set to true, and a document can carry is_protected. The context menu hides rename and delete on a secure folder or a protected document. But a hidden button can be bypassed with a plain request, so deletion is also filtered in the 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 compares the password with its bcrypt hash (bcryptjs, cost 10) and returns a JWT valid for 24 hours, signed in lib/auth.ts. The browser keeps it in localStorage and sends it in the Authorization header. Every admin route that writes calls verifyToken before acting. Apart from statistics (/api/analytics), read routes stay public: the site needs them for the profile and the projects.
Diplomas that file themselves into folders
A diploma has its metadata in diplomas and its files in diploma_files. On creation or update, syncDiplomaToDocuments creates a [Title] - [date] folder under "Mes Diplômes", then adds one row per file to documents. The file is not copied: the row points to the same file_path. Before each insert, the folder is looked up by name and parent, the document by filename and folder. The "Sync Diplômes" button (/api/documents/sync-diplomas) can therefore be run again without creating duplicates.
That button is genuinely needed: on creation, the diploma is saved before its files are sent, and the upload does not rerun the synchronisation. The folder starts out empty, and the files only arrive with the next update or through that button.
For the CV, I keep every version in cv_files, and only one carries is_current. setCurrentCV switches the active version and updates the profile in the same transaction.
A showcase wired to the same database
createProject and updateProject write the project and its project_technologies links in a transaction, with a ROLLBACK on error. /api/featured caps featured projects at four, ordered by the position column that updateFeaturedProjects rewrites on every change.
The home page reads this data at runtime: ProjectsPreviewDynamic queries /api/projects, ProfileContext loads /api/profile. On every page, BackgroundAnimation animates fifty particles on a canvas, joined by a line when they are less than 100 pixels apart.
The top of the public home page: the title and links come from the profile loaded by ProfileContext, and the dots in the background are the BackgroundAnimation particles.
What the README promised and the code did not deliver
The README promises type validation and checks on uploaded files. In practice:
- Formats are only filtered by the
acceptattribute of the<input>. A dragged-and-dropped file, or one sent straight to the API, goes through unchecked. Only the CV route checks type and size: a PDF of 50 MB at most. - "Protected" protects from deletion, not from modification or reading.
updateDocumentFolderrenames a native folder too, andupdateDocumentaccepts settingis_protectedback to false. Files live inpublic/, served as is, and the document list can be read without a token. Today I would serve diplomas from a private folder, through an authenticated route. - Not everything is wired up.
/projetsshows sample projects written into the page, the home page skills are hardcoded inSkillsSection, the contact form simulates sending with asetTimeout, andAdvancedDashboardshows mock statistics. - Diploma synchronisation exists in three copies, each with its own
Pool, and the schema evolved through fix-up scripts (fix-secure-folders.cjs), not migrations.
What I take away is that access control is checked where the files are served, not in the menu that displays them.