Ciné Délices - univers cinéma & gastronomie
Case Study · Ciné Délices · 2025

Ciné Délices
du design au déploiement.

Direction artistique forte, UX soignée, 15 ans de sens du détail appliqués au code. Un projet de formation livré comme un vrai produit — stack Node.js full-stack, sécurité OWASP réelle.

Node.jsExpressPostgreSQLArgon2JWTSCSS
01 / 01
Le défi

Faire dépasser le projet le cadre formation.

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.

01

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.

02

Sécurité réelle
pas une checklist

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.

03

UX premium
sur stack vanilla

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.

4 décisions techniques

Du code qu'on relit parce qu'il a une raison d'être.

Chaque snippet ci-dessous résout un problème concret. Pas du code-vitrine : du code qui tourne en production sur cinedelices.com.

Décision 01

Parallaxe maison
avec easing maths.

Hero du site : trois plans qui dérivent à des vitesses différentes au scroll. Pas une lib. Ratio + easing cubic, requestAnimationFrame, will-change.

  • Aucune dépendance, 40 lignes de JS lisibles
  • Compositor-only : transform3d, jamais de top/left
  • Easing cubic pour amortir aux extrêmes du scroll
  • rAF qui s'arrête en idle, ~70% moins de CPU
JavaScriptrequestAnimationFramePerformanceMath
js/parallax.js
// 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 });
Décision 02

Debounce dynamique selon la longueur

Recherche live sur 500k+ films TMDB. Un debounce fixe à 300ms est trop lent pour « avatar » et inutilement prudent pour « z ».

  • Délai adapté à la longueur de la query
  • 1 caractère = 500ms (l'utilisateur tape encore)
  • 4+ caractères = 150ms (la requête est utile)
  • AbortController : annule les requêtes obsolètes
UX engineeringAbortControllerFetch
js/search.js
// 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));
});
Décision 03

Rating preview / committed, à la state-machine

Étoiles cliquables avec preview au survol. Le piège classique : le hover modifie l'état réel et le clic n'est plus distinguable. Solution : deux états séparés.

  • committed = la note enregistrée
  • preview = la note survolée (volatile)
  • L'affichage est toujours preview ?? committed
  • Accessible clavier : flèches gauche/droite
State designA11yComponent patterns
js/rating.js
// 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);
    });
  }
}
Décision 04

Carrousel avec flag isProgrammatic

Carrousel d'affiches avec navigation flèches + scroll natif. Bug subtil : quand on clique « next », le scrollIntoView déclenche un événement scroll qui croit que c'est l'utilisateur. Solution : un flag.

  • Flag levé pendant un scroll programmé
  • Le handler scroll ignore les events tant que flag haut
  • Reset après scrollend (ou timeout fallback)
  • Plus de double-trigger, plus de saut visuel
Browser internalsscrollscrollendUX
js/carousel.js
// 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);
  }
}
Décision 05

Sécurité OWASP-grade, pas un placebo

Beaucoup de projets de formation cochent « auth + JWT » et s'arrêtent là. On a poussé jusqu'à la couverture OWASP Top-10 : XSS, CSRF, injections, brute-force, headers.

  • Argon2id (et non bcrypt) avec coût adapté
  • Helmet + CSP custom + rate limiter par route sensible
  • JWT en cookie httpOnly + SameSite=Strict
  • Validation Joi + sanitization XSS sur 100% des inputs
OWASPArgon2HelmetJoiJWT
server/middleware/security.js
// 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,
});
Architecture

Stack volontairement vanilla, back robuste.

Pas de React côté front : prouver qu'on sait tenir un projet à la main. Côté serveur, Node + Express + PostgreSQL, éprouvé, scalable, debuggable.

HTML5 sémantique
Front
SCSS · BEM
Front
JavaScript
Front · No-lib
Node · Express
Back
PostgreSQL
DB
Sécurité

Pas un cocher de cases :
une vraie défense en profondeur.

Argon2id
Paramètres OWASP 2024. Le standard moderne pour le hashing, supérieur à bcrypt face aux GPU.
Helmet · CSP
Headers HTTP de sécurité, content security policy stricte, scriptSrc whitelistée.
Rate limiting
5 tentatives auth / 15 min / IP. 120 req / min global. Brute-force et DoS basique bloqués.
Joi · Sanitize
Validation serveur sur 100% des inputs. XSS sanitization automatique sur les strings du body.
JWT httpOnly
Tokens en cookie httpOnly + SameSite=Strict + Secure. Inaccessibles au JS, immunisés CSRF.
Pas de SQL brut
100% prepared statements. Injections SQL impossibles by design, pas de concaténation de query.
Errors muted
Les erreurs serveur ne fuitent jamais la stack ou le SQL, un message générique côté client.
Auth stricte
Middleware sur chaque route protégée. Roles user/admin granulaires, pas de bypass possible.
Évolutions prévues

Ce qui est livré, ce qui suivra.

Le site est en ligne sur cinedelices.com, hébergé sur Render. Voici les prochaines évolutions.

Livré

Déployé sur Render

Hébergement Node.js sur Render, base PostgreSQL managée, déploiement continu depuis GitHub, domaine cinedelices.com.

Sprint+1

Recherche full-text Postgres

Index GIN + tsvector pour passer la recherche films à la milliseconde, avec ranking par pertinence.

Sprint+2

Recommandations contextuelles

Système de scoring basé sur les genres + recettes appréciées + signaux d'engagement (temps, retours).

Refonte v2

Front React + API découplée

Migration progressive : extraction de l'API, front React/TanStack Query, conservation du back actuel.