Identité forte
vs minutage serré
Direction artistique « pirate cinéma » : Bebas Neue, or, bleu nuit, Playfair italique. Aucune lib graphique, tout maison, parallaxe et carrousel codés à la main. 12 semaines pour livrer.
Trois tensions à résoudre : un cahier des charges DWWM, une équipe de 5, et l'envie de produire un site qui ne ressemble pas à un exercice.
Direction artistique « pirate cinéma » : Bebas Neue, or, bleu nuit, Playfair italique. Aucune lib graphique, tout maison, parallaxe et carrousel codés à la main. 12 semaines pour livrer.
Argon2 sur tous les hashes, Helmet avec CSP custom, rate limiting par route, validation Joi serveur, sanitization XSS, JWT en cookie httpOnly. Couverture OWASP Top-10.
Pas de framework UI. Donc : debounce dynamique selon la longueur du texte, rating preview/committed à la state-machine, isProgrammatic flag pour distinguer scroll user du scroll auto. Browser internals.
Chaque snippet ci-dessous résout un problème concret. Pas du code-vitrine : du code qui tourne en production sur cinedelices.com.
// Parallaxe scroll : 3 plans à vitesses différentes // Easing cubic + rAF + transform3d compositor-only const layers = [ { el: $('.hero-bg'), speed: 0.18 }, { el: $('.hero-mid'), speed: 0.42 }, { el: $('.hero-front'), speed: 0.72 }, ]; let targetY = 0, currentY = 0, raf = 0; // easeOutCubic : amortit les extrêmes du scroll const easeOut = (t) => 1 - Math.pow(1 - t, 3); function tick() { const delta = targetY - currentY; currentY += delta * 0.12; layers.forEach(({ el, speed }) => { const y = currentY * speed; const eased = easeOut(Math.min(1, y / 800)) * y; el.style.transform = `translate3d(0, ${eased}px, 0)`; }); raf = Math.abs(delta) > 0.4 ? requestAnimationFrame(tick) : 0; } addEventListener('scroll', () => { targetY = window.scrollY; if (!raf) raf = requestAnimationFrame(tick); }, { passive: true });
// Debounce dynamique : plus on tape, plus on est rapide const input = $('.search-input'); let timer, controller; function delayFor(query) { const n = query.length; if (n === 0) return 0; if (n < 2) return 500; if (n < 4) return 280; return 150; } input.addEventListener('input', (e) => { clearTimeout(timer); controller?.abort(); const q = e.target.value.trim(); if (!q) return renderEmpty(); timer = setTimeout(async () => { controller = new AbortController(); try { const res = await fetch( `/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal } ); render(await res.json()); } catch (err) { if (err.name !== 'AbortError') renderError(); } }, delayFor(q)); });
// Rating : 2 états séparés (committed + preview) class StarRating { constructor(root, { initial = 0, onChange } = {}) { this.root = root; this.committed = initial; this.preview = null; this.onChange = onChange; this.bind(); this.render(); } get displayed() { return this.preview ?? this.committed; } bind() { this.root.querySelectorAll('[data-v]').forEach(s => { const v = +s.dataset.v; s.addEventListener('mouseenter', () => { this.preview = v; this.render(); }); s.addEventListener('click', () => this.commit(v)); }); this.root.addEventListener('mouseleave', () => { this.preview = null; this.render(); }); this.root.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft') this.commit(Math.max(0, this.committed - 1)); if (e.key === 'ArrowRight') this.commit(Math.min(5, this.committed + 1)); }); } commit(v) { this.committed = v; this.preview = null; this.render(); this.onChange?.(v); } render() { const v = this.displayed; this.root.querySelectorAll('[data-v]').forEach(s => { s.classList.toggle('on', +s.dataset.v <= v); }); } }
// Carrousel : flag isProgrammatic class Carousel { constructor(track) { this.track = track; this.isProgrammatic = false; this.idx = 0; this.bind(); } bind() { this.track.addEventListener('scroll', () => { if (this.isProgrammatic) return; this.syncIndexFromScroll(); }, { passive: true }); $('.next').addEventListener('click', () => this.go(+1)); $('.prev').addEventListener('click', () => this.go(-1)); } go(dir) { const next = Math.max(0, Math.min(this.max, this.idx + dir)); this.idx = next; this.scrollToIndex(next); } scrollToIndex(i) { this.isProgrammatic = true; const card = this.track.children[i]; this.track.scrollTo({ left: card.offsetLeft - 12, behavior: 'smooth' }); const reset = () => { this.isProgrammatic = false; }; this.track.addEventListener('scrollend', reset, { once: true }); setTimeout(reset, 700); } }
// Sécurité serveur : Helmet + rate limit + CSP import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import argon2 from 'argon2'; import { sanitize } from './xss.js'; export function applySecurity(app) { app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], imgSrc: ["'self'", "https://image.tmdb.org"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], }, }, crossOriginEmbedderPolicy: false, })); app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 5, standardHeaders: true, message: { error: 'Trop de tentatives, réessayez plus tard.' }, })); app.use(rateLimit({ windowMs: 60_000, max: 120 })); app.use((req, _res, next) => { if (req.body && typeof req.body === 'object') { for (const k in req.body) { if (typeof req.body[k] === 'string') { req.body[k] = sanitize(req.body[k]); } } } next(); }); } export const hashPassword = (pw) => argon2.hash(pw, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1, });
Pas de React côté front : prouver qu'on sait tenir un projet à la main. Côté serveur, Node + Express + PostgreSQL, éprouvé, scalable, debuggable.
Le site est en ligne sur cinedelices.com, hébergé sur Render. Voici les prochaines évolutions.
Hébergement Node.js sur Render, base PostgreSQL managée, déploiement continu depuis GitHub, domaine cinedelices.com.
Index GIN + tsvector pour passer la recherche films à la milliseconde, avec ranking par pertinence.
Système de scoring basé sur les genres + recettes appréciées + signaux d'engagement (temps, retours).
Migration progressive : extraction de l'API, front React/TanStack Query, conservation du back actuel.