From b785db1b84f31c4622396b0939438721a2d53e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?LAINEY=20Gr=C3=A9gory?= Date: Tue, 29 Sep 2026 10:45:14 +0200 Subject: [PATCH] projet par cursor --- .env.example | 14 + .gitignore | 9 + Dockerfile | 21 + README.md | 80 +++ data/cache/.gitkeep | 0 data/jaquettes/.gitkeep | 0 docker-compose.yml | 20 + docker/apache.conf | 12 + docker/php.ini | 8 + public/.htaccess | 9 + public/assets/css/style.css | 791 +++++++++++++++++++++++ public/assets/img/jaquette-manquante.svg | 13 + public/assets/js/app.js | 108 ++++ public/index.php | 10 + public/servir-jaquette.php | 36 ++ src/Auth.php | 135 ++++ src/Config.php | 41 ++ src/Controllers/AccueilController.php | 29 + src/Controllers/AdminController.php | 76 +++ src/Controllers/AuthController.php | 91 +++ src/Controllers/ConsoleController.php | 137 ++++ src/Controllers/JeuController.php | 267 ++++++++ src/Csrf.php | 46 ++ src/Flash.php | 29 + src/Helpers.php | 79 +++ src/JsonStore.php | 162 +++++ src/Router.php | 66 ++ src/Services/ConsoleService.php | 133 ++++ src/Services/EnrichissementService.php | 250 +++++++ src/Services/JeuService.php | 249 +++++++ src/Services/UtilisateurService.php | 108 ++++ src/View.php | 31 + src/bootstrap.php | 82 +++ templates/accueil.php | 71 ++ templates/admin/utilisateurs.php | 64 ++ templates/auth/connexion.php | 39 ++ templates/auth/inscription.php | 53 ++ templates/consoles/detail.php | 49 ++ templates/consoles/formulaire.php | 48 ++ templates/consoles/liste.php | 48 ++ templates/erreurs/404.php | 5 + templates/jeux/detail.php | 73 +++ templates/jeux/formulaire.php | 126 ++++ templates/jeux/liste.php | 73 +++ templates/layout.php | 75 +++ templates/layout_auth.php | 29 + templates/partials/flash.php | 7 + 47 files changed, 3902 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 data/cache/.gitkeep create mode 100644 data/jaquettes/.gitkeep create mode 100644 docker-compose.yml create mode 100644 docker/apache.conf create mode 100644 docker/php.ini create mode 100644 public/.htaccess create mode 100644 public/assets/css/style.css create mode 100644 public/assets/img/jaquette-manquante.svg create mode 100644 public/assets/js/app.js create mode 100644 public/index.php create mode 100644 public/servir-jaquette.php create mode 100644 src/Auth.php create mode 100644 src/Config.php create mode 100644 src/Controllers/AccueilController.php create mode 100644 src/Controllers/AdminController.php create mode 100644 src/Controllers/AuthController.php create mode 100644 src/Controllers/ConsoleController.php create mode 100644 src/Controllers/JeuController.php create mode 100644 src/Csrf.php create mode 100644 src/Flash.php create mode 100644 src/Helpers.php create mode 100644 src/JsonStore.php create mode 100644 src/Router.php create mode 100644 src/Services/ConsoleService.php create mode 100644 src/Services/EnrichissementService.php create mode 100644 src/Services/JeuService.php create mode 100644 src/Services/UtilisateurService.php create mode 100644 src/View.php create mode 100644 src/bootstrap.php create mode 100644 templates/accueil.php create mode 100644 templates/admin/utilisateurs.php create mode 100644 templates/auth/connexion.php create mode 100644 templates/auth/inscription.php create mode 100644 templates/consoles/detail.php create mode 100644 templates/consoles/formulaire.php create mode 100644 templates/consoles/liste.php create mode 100644 templates/erreurs/404.php create mode 100644 templates/jeux/detail.php create mode 100644 templates/jeux/formulaire.php create mode 100644 templates/jeux/liste.php create mode 100644 templates/layout.php create mode 100644 templates/layout_auth.php create mode 100644 templates/partials/flash.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..63087a5 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Port exposé sur la machine hôte +APP_PORT=8080 + +# Compte administrateur créé au premier démarrage +ADMIN_IDENTIFIANT=admin +ADMIN_MOT_DE_PASSE=admin123 +ADMIN_EMAIL=glainey@planete-online.fr + +# Clé API RAWG (https://rawg.io/apidocs) — optionnelle +# Sans clé, la saisie manuelle des jeux reste possible +RAWG_API_KEY= + +# Durée de vie de la session en secondes (défaut : 24 h) +SESSION_LIFETIME=86400 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b893a85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.git +.env +data/utilisateurs.json +data/consoles.json +data/jeux.json +data/jaquettes/* +data/cache/* +!data/jaquettes/.gitkeep +!data/cache/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4669d62 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM php:8.3-apache + +RUN a2enmod rewrite headers \ + && docker-php-ext-install opcache + +COPY docker/apache.conf /etc/apache2/sites-available/000-default.conf +COPY docker/php.ini /usr/local/etc/php/conf.d/jeuxvideotech.ini + +WORKDIR /var/www/html + +COPY public /var/www/html/public +COPY src /var/www/html/src +COPY templates /var/www/html/templates +COPY data /var/www/html/data + +RUN mkdir -p /var/www/html/data/jaquettes /var/www/html/data/cache \ + && chown -R www-data:www-data /var/www/html/data + +ENV APACHE_DOCUMENT_ROOT=/var/www/html/public + +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..70747d6 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# JeuxVideoTech + +Application web multi-utilisateurs pour gérer une bibliothèque personnelle de **consoles** et de **jeux vidéo**. + +Stack : PHP 8.3 + Apache, fichiers JSON, Docker Compose, enrichissement optionnel via l’API [RAWG](https://rawg.io/apidocs). + +## Démarrage rapide + +```bash +cp .env.example .env +docker compose up -d --build +``` + +Ouvrir [http://localhost:8080](http://localhost:8080). + +Compte administrateur créé automatiquement au premier démarrage (valeurs dans `.env`) : + +| Champ | Valeur par défaut | +|---|---| +| Identifiant | `admin` | +| Mot de passe | `admin123` | + +**Changez ces valeurs avant tout usage réel.** + +## Fonctionnalités + +- Inscription / connexion / déconnexion (sessions PHP, CSRF, mots de passe hashés) +- Isolation des données par compte +- CRUD consoles et jeux +- Recherche et filtres sur les jeux (console, état, texte) +- Enrichissement automatique (jaquette, description, date, genres, éditeur / développeur) si `RAWG_API_KEY` est définie +- Administration des comptes (rôle `admin`) +- Persistance via volume Docker (`./data`) + +## Configuration + +Variables dans `.env` : + +| Variable | Description | +|---|---| +| `APP_PORT` | Port hôte (défaut `8080`) | +| `ADMIN_IDENTIFIANT` | Identifiant du compte admin initial | +| `ADMIN_MOT_DE_PASSE` | Mot de passe du compte admin initial | +| `ADMIN_EMAIL` | E-mail du compte admin | +| `RAWG_API_KEY` | Clé API RAWG (optionnelle) | +| `SESSION_LIFETIME` | Durée de session en secondes | + +Sans clé RAWG, l’ajout manuel des jeux reste possible. + +## Structure + +``` +public/ Point d’entrée Apache + assets +src/ Bootstrap, auth, services, contrôleurs +templates/ Vues PHP +data/ JSON + jaquettes + cache API (volume) +docker/ Config Apache / PHP +``` + +## États de jeu + +- `possede` — Possédé +- `a_acheter` — À acheter +- `prete` — Prêté +- `vendu` — Vendu + +## Commandes utiles + +```bash +# Lancer +docker compose up -d --build + +# Logs +docker compose logs -f app + +# Arrêter +docker compose down +``` + +Les données (`data/*.json`, jaquettes) survivent au redémarrage du conteneur grâce au volume monté. diff --git a/data/cache/.gitkeep b/data/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/jaquettes/.gitkeep b/data/jaquettes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..77ca1bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + app: + build: . + container_name: jeuxvideotech + ports: + - "${APP_PORT:-8080}:80" + volumes: + - ./data:/var/www/html/data + - ./public:/var/www/html/public + - ./src:/var/www/html/src + - ./templates:/var/www/html/templates + environment: + APP_NAME: "JeuxVideoTech" + APP_URL: "http://localhost:${APP_PORT:-8080}" + ADMIN_IDENTIFIANT: "${ADMIN_IDENTIFIANT:-admin}" + ADMIN_MOT_DE_PASSE: "${ADMIN_MOT_DE_PASSE:-admin123}" + ADMIN_EMAIL: "${ADMIN_EMAIL:-glainey@planete-online.fr}" + RAWG_API_KEY: "${RAWG_API_KEY:-}" + SESSION_LIFETIME: "${SESSION_LIFETIME:-86400}" + restart: unless-stopped diff --git a/docker/apache.conf b/docker/apache.conf new file mode 100644 index 0000000..4b00b55 --- /dev/null +++ b/docker/apache.conf @@ -0,0 +1,12 @@ + + DocumentRoot /var/www/html/public + + + AllowOverride All + Require all granted + Options -Indexes +FollowSymLinks + + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + diff --git a/docker/php.ini b/docker/php.ini new file mode 100644 index 0000000..6538a42 --- /dev/null +++ b/docker/php.ini @@ -0,0 +1,8 @@ +display_errors = Off +log_errors = On +error_reporting = E_ALL +session.cookie_httponly = 1 +session.cookie_samesite = Lax +session.use_strict_mode = 1 +upload_max_filesize = 8M +post_max_size = 10M diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..caf5edf --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,9 @@ +RewriteEngine On + +# Servir les jaquettes stockées hors de public/ +RewriteRule ^jaquettes/(.*)$ /servir-jaquette.php?fichier=$1 [L,QSA] + +# Fichiers / dossiers existants : ne pas réécrire +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [L] diff --git a/public/assets/css/style.css b/public/assets/css/style.css new file mode 100644 index 0000000..d817298 --- /dev/null +++ b/public/assets/css/style.css @@ -0,0 +1,791 @@ +:root +{ + --bg-deep: #07101c; + --bg-panel: rgba(12, 24, 40, 0.88); + --bg-elevated: #122338; + --line: rgba(0, 229, 255, 0.18); + --text: #e8f4ff; + --text-muted: #8aa0b8; + --accent: #00e5ff; + --accent-hot: #7cff6b; + --danger: #ff5d7a; + --warning: #ffc857; + --shadow: 0 18px 40px rgba(0, 0, 0, 0.35); + --radius: 14px; + --font-display: "Orbitron", sans-serif; + --font-body: "Rajdhani", sans-serif; +} + +* +{ + box-sizing: border-box; +} + +html, +body +{ + min-height: 100%; +} + +body.app-body +{ + margin: 0; + font-family: var(--font-body); + color: var(--text); + background: + radial-gradient(circle at 12% 18%, rgba(0, 229, 255, 0.12), transparent 28%), + radial-gradient(circle at 88% 8%, rgba(124, 255, 107, 0.08), transparent 24%), + linear-gradient(160deg, #050b14 0%, #0a1628 45%, #07101c 100%); + background-attachment: fixed; + position: relative; +} + +body.app-body::before +{ + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(0, 229, 255, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 229, 255, 0.035) 1px, transparent 1px); + background-size: 48px 48px; + z-index: 0; +} + +.scanlines +{ + position: fixed; + inset: 0; + pointer-events: none; + z-index: 1; + background: repeating-linear-gradient( + to bottom, + transparent 0, + transparent 2px, + rgba(0, 0, 0, 0.08) 3px + ); + opacity: 0.35; +} + +.container, +.app-nav, +.app-footer, +.auth-shell +{ + position: relative; + z-index: 2; +} + +a +{ + color: var(--accent); + text-decoration: none; +} + +a:hover +{ + color: var(--accent-hot); +} + +.app-nav +{ + background: rgba(5, 12, 22, 0.85); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(10px); +} + +.navbar-brand.brand-logo, +.auth-brand h1 +{ + font-family: var(--font-display); + letter-spacing: 0.08em; + color: var(--text); + font-size: 1.15rem; + display: inline-flex; + align-items: center; + gap: 0.55rem; +} + +.brand-mark +{ + color: var(--accent); + text-shadow: 0 0 12px rgba(0, 229, 255, 0.7); + animation: pulseMark 2.4s ease-in-out infinite; +} + +@keyframes pulseMark +{ + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.65; transform: scale(0.92); } +} + +.app-nav .nav-link, +.nav-user +{ + color: var(--text-muted); + font-weight: 600; + letter-spacing: 0.03em; +} + +.app-nav .nav-link:hover, +.app-nav .nav-link:focus +{ + color: var(--accent); +} + +.nav-user +{ + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.navbar-toggler +{ + border-color: var(--line); +} + +.navbar-toggler-icon +{ + filter: invert(1); +} + +.page-header +{ + display: flex; + justify-content: space-between; + gap: 1.5rem; + align-items: flex-start; + margin-bottom: 1.75rem; + flex-wrap: wrap; +} + +.eyebrow +{ + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.78rem; + color: var(--accent); + margin-bottom: 0.35rem; +} + +.page-title +{ + font-family: var(--font-display); + font-size: clamp(1.6rem, 3vw, 2.3rem); + margin: 0; + letter-spacing: 0.04em; +} + +.lead-text +{ + color: var(--text-muted); + margin: 0.4rem 0 0; + font-size: 1.05rem; +} + +.page-actions +{ + display: flex; + gap: 0.65rem; + flex-wrap: wrap; +} + +.btn-neon, +.btn-outline-neon, +.btn-outline-danger +{ + font-family: var(--font-body); + font-weight: 700; + letter-spacing: 0.04em; + border-radius: 999px; + padding: 0.45rem 1.1rem; + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.btn-neon +{ + background: linear-gradient(120deg, var(--accent), #39b7ff); + border: none; + color: #041018; + box-shadow: 0 0 18px rgba(0, 229, 255, 0.25); +} + +.btn-neon:hover +{ + color: #041018; + transform: translateY(-1px); + box-shadow: 0 0 24px rgba(0, 229, 255, 0.4); +} + +.btn-outline-neon +{ + background: transparent; + border: 1px solid var(--accent); + color: var(--accent); +} + +.btn-outline-neon:hover +{ + background: rgba(0, 229, 255, 0.1); + color: var(--accent); + transform: translateY(-1px); +} + +.btn-outline-danger +{ + background: transparent; + border: 1px solid var(--danger); + color: var(--danger); +} + +.btn-outline-danger:hover +{ + background: rgba(255, 93, 122, 0.12); + color: var(--danger); +} + +.stats-row +{ + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.stat-block, +.form-panel, +.table-panel, +.console-card, +.empty-state, +.filter-bar +{ + background: var(--bg-panel); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.stat-block +{ + padding: 1.25rem 1.4rem; +} + +.stat-label +{ + display: block; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.8rem; +} + +.stat-value +{ + font-family: var(--font-display); + font-size: 2.2rem; + color: var(--accent-hot); +} + +.section-heading +{ + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin-bottom: 1rem; +} + +.section-heading h2, +.form-panel h2, +.detail-info h2 +{ + font-family: var(--font-display); + font-size: 1.1rem; + letter-spacing: 0.06em; + margin: 0; +} + +.link-neon +{ + font-weight: 700; +} + +.game-grid +{ + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1.1rem; +} + +.game-card +{ + display: flex; + flex-direction: column; + background: var(--bg-panel); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + color: inherit; + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.game-card:hover +{ + transform: translateY(-4px); + border-color: rgba(0, 229, 255, 0.45); + box-shadow: 0 16px 32px rgba(0, 229, 255, 0.12); + color: inherit; +} + +.game-card img +{ + width: 100%; + aspect-ratio: 3 / 4; + object-fit: cover; + background: var(--bg-elevated); +} + +.game-card-body +{ + padding: 0.85rem 0.9rem 1rem; +} + +.game-card-body h3 +{ + font-size: 1.05rem; + margin: 0 0 0.25rem; + font-weight: 700; +} + +.game-card-body p +{ + margin: 0 0 0.55rem; + color: var(--text-muted); + font-size: 0.95rem; +} + +.badge-etat +{ + display: inline-flex; + align-items: center; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + border: 1px solid transparent; +} + +.etat-possede +{ + color: #041018; + background: var(--accent-hot); +} + +.etat-a_acheter +{ + color: #041018; + background: var(--warning); +} + +.etat-prete +{ + color: #041018; + background: var(--accent); +} + +.etat-vendu +{ + color: #fff; + background: #5a6a7d; +} + +.console-grid +{ + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1rem; +} + +.console-card +{ + padding: 1.1rem; + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.console-card-link +{ + color: inherit; +} + +.console-card-link:hover +{ + color: inherit; +} + +.console-icon +{ + width: 2.6rem; + height: 2.6rem; + border-radius: 10px; + display: grid; + place-items: center; + background: rgba(0, 229, 255, 0.1); + border: 1px solid var(--line); + color: var(--accent); + font-size: 1.4rem; + margin-bottom: 0.7rem; +} + +.console-card h2 +{ + font-size: 1.25rem; + margin: 0; + font-weight: 700; +} + +.console-card p +{ + margin: 0.25rem 0 0.5rem; + color: var(--text-muted); +} + +.console-count +{ + color: var(--accent-hot); + font-weight: 700; +} + +.console-actions +{ + display: flex; + gap: 0.45rem; +} + +.empty-state +{ + text-align: center; + padding: 2.5rem 1.5rem; + color: var(--text-muted); +} + +.empty-state i +{ + font-size: 2.8rem; + color: var(--accent); + display: block; + margin-bottom: 0.7rem; +} + +.form-panel, +.filter-bar, +.table-panel +{ + padding: 1.35rem; +} + +.form-label +{ + font-weight: 600; + color: var(--text-muted); +} + +.form-control, +.form-select +{ + background: rgba(4, 12, 22, 0.75); + border: 1px solid var(--line); + color: var(--text); + border-radius: 10px; +} + +.form-control:focus, +.form-select:focus +{ + background: rgba(4, 12, 22, 0.9); + border-color: var(--accent); + box-shadow: 0 0 0 0.2rem rgba(0, 229, 255, 0.15); + color: var(--text); +} + +.form-text, +.text-muted +{ + color: var(--text-muted) !important; +} + +.form-actions +{ + margin-top: 1.35rem; + display: flex; + gap: 0.75rem; +} + +.detail-hero +{ + display: grid; + grid-template-columns: 220px 1fr; + gap: 1.5rem; + align-items: start; +} + +.detail-cover img, +.cover-preview img +{ + width: 100%; + border-radius: var(--radius); + border: 1px solid var(--line); + box-shadow: var(--shadow); + aspect-ratio: 3 / 4; + object-fit: cover; + background: var(--bg-elevated); +} + +.detail-meta +{ + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1rem; + color: var(--text-muted); + margin-top: 0.8rem; +} + +.detail-grid +{ + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 1rem; +} + +.detail-text, +.info-list dd +{ + color: var(--text); + line-height: 1.55; +} + +.info-list +{ + margin: 0; +} + +.info-list > div +{ + padding: 0.65rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.info-list dt +{ + color: var(--text-muted); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.info-list dd +{ + margin: 0.2rem 0 0; +} + +.app-table +{ + color: var(--text); +} + +.app-table thead th +{ + color: var(--text-muted); + text-transform: uppercase; + font-size: 0.8rem; + letter-spacing: 0.08em; + border-bottom-color: var(--line); +} + +.app-table td +{ + border-bottom-color: rgba(255, 255, 255, 0.06); +} + +.app-alert +{ + border: 1px solid var(--line); + background: rgba(12, 24, 40, 0.95); + color: var(--text); +} + +.alert-success +{ + border-color: rgba(124, 255, 107, 0.35); +} + +.alert-danger +{ + border-color: rgba(255, 93, 122, 0.35); +} + +.alert-warning +{ + border-color: rgba(255, 200, 87, 0.35); +} + +.app-footer +{ + margin-top: 3rem; + padding: 1.2rem 0 2rem; + color: var(--text-muted); + border-top: 1px solid var(--line); + font-size: 0.95rem; +} + +.footer-sep +{ + margin: 0 0.45rem; +} + +/* Auth */ +.auth-body +{ + display: grid; + place-items: center; + min-height: 100vh; + padding: 1.5rem; +} + +.auth-shell +{ + width: min(960px, 100%); + display: grid; + grid-template-columns: 1.1fr 0.9fr; + gap: 1.25rem; +} + +.auth-brand, +.auth-panel +{ + background: var(--bg-panel); + border: 1px solid var(--line); + border-radius: calc(var(--radius) + 4px); + box-shadow: var(--shadow); + padding: 2rem; +} + +.auth-brand +{ + display: flex; + flex-direction: column; + justify-content: center; + background: + linear-gradient(145deg, rgba(0, 229, 255, 0.12), transparent 55%), + var(--bg-panel); +} + +.auth-brand .brand-mark +{ + font-size: 2rem; + margin-bottom: 0.8rem; +} + +.auth-brand p +{ + color: var(--text-muted); + margin: 0.7rem 0 0; + max-width: 28ch; +} + +.auth-title +{ + font-family: var(--font-display); + font-size: 1.35rem; + margin-bottom: 0.35rem; +} + +.auth-subtitle, +.auth-switch +{ + color: var(--text-muted); +} + +.auth-switch +{ + margin-top: 1.2rem; + text-align: center; +} + +.api-results +{ + display: grid; + gap: 0.75rem; +} + +.api-result +{ + display: grid; + grid-template-columns: 56px 1fr auto; + gap: 0.85rem; + align-items: center; + padding: 0.7rem; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(4, 12, 22, 0.55); +} + +.api-result img +{ + width: 56px; + height: 74px; + object-fit: cover; + border-radius: 6px; + background: var(--bg-elevated); +} + +.api-result h3 +{ + margin: 0; + font-size: 1.05rem; +} + +.api-result p +{ + margin: 0.2rem 0 0; + color: var(--text-muted); + font-size: 0.92rem; +} + +@media (max-width: 900px) +{ + .auth-shell, + .detail-hero, + .detail-grid + { + grid-template-columns: 1fr; + } + + .detail-cover, + .cover-preview + { + max-width: 220px; + } +} + +@media (max-width: 640px) +{ + .stats-row + { + grid-template-columns: 1fr; + } + + .api-result + { + grid-template-columns: 56px 1fr; + } + + .api-result .btn + { + grid-column: 1 / -1; + } +} diff --git a/public/assets/img/jaquette-manquante.svg b/public/assets/img/jaquette-manquante.svg new file mode 100644 index 0000000..20679a9 --- /dev/null +++ b/public/assets/img/jaquette-manquante.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + Pas de jaquette + diff --git a/public/assets/js/app.js b/public/assets/js/app.js new file mode 100644 index 0000000..6d5462b --- /dev/null +++ b/public/assets/js/app.js @@ -0,0 +1,108 @@ +document.addEventListener("DOMContentLoaded", () => +{ + const boutonRecherche = document.getElementById("btnRechercheApi"); + const champRecherche = document.getElementById("rechercheApi"); + const zoneResultats = document.getElementById("resultatsApi"); + const formAppliquer = document.getElementById("formAppliquerApi"); + const champRawgId = document.getElementById("rawgId"); + + if (!boutonRecherche || !champRecherche || !zoneResultats) + { + return; + } + + const lancerRecherche = async () => + { + const terme = champRecherche.value.trim(); + + if (terme === "") + { + zoneResultats.hidden = true; + zoneResultats.innerHTML = ""; + return; + } + + boutonRecherche.disabled = true; + boutonRecherche.textContent = "Recherche…"; + + try + { + const reponse = await fetch("/jeux/api/rechercher?q=" + encodeURIComponent(terme)); + const donnees = await reponse.json(); + afficherResultats(donnees.resultats || []); + } + catch (erreur) + { + zoneResultats.hidden = false; + zoneResultats.innerHTML = "

Impossible de contacter l'API pour le moment.

"; + } + finally + { + boutonRecherche.disabled = false; + boutonRecherche.textContent = "Rechercher"; + } + }; + + const afficherResultats = (resultats) => + { + zoneResultats.hidden = false; + + if (resultats.length === 0) + { + zoneResultats.innerHTML = "

Aucun résultat trouvé.

"; + return; + } + + zoneResultats.innerHTML = ""; + + resultats.forEach((jeu) => + { + const carte = document.createElement("article"); + carte.className = "api-result"; + + const image = document.createElement("img"); + image.src = jeu.jaquetteUrl || "/assets/img/jaquette-manquante.svg"; + image.alt = "Jaquette de " + (jeu.titre || "jeu"); + + const infos = document.createElement("div"); + const titre = document.createElement("h3"); + titre.textContent = jeu.titre || "Sans titre"; + const meta = document.createElement("p"); + const genres = (jeu.genres || []).join(", "); + meta.textContent = [jeu.dateSortie || "Date inconnue", genres].filter(Boolean).join(" · "); + infos.appendChild(titre); + infos.appendChild(meta); + + const bouton = document.createElement("button"); + bouton.type = "button"; + bouton.className = "btn btn-sm btn-neon"; + bouton.textContent = "Utiliser"; + bouton.addEventListener("click", () => + { + if (!formAppliquer || !champRawgId) + { + return; + } + + champRawgId.value = String(jeu.rawgId); + formAppliquer.submit(); + }); + + carte.appendChild(image); + carte.appendChild(infos); + carte.appendChild(bouton); + zoneResultats.appendChild(carte); + }); + }; + + boutonRecherche.addEventListener("click", lancerRecherche); + + champRecherche.addEventListener("keydown", (event) => + { + if (event.key === "Enter") + { + event.preventDefault(); + lancerRecherche(); + } + }); +}); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..b547bec --- /dev/null +++ b/public/index.php @@ -0,0 +1,10 @@ +dispatcher( + $_SERVER["REQUEST_METHOD"] ?? "GET", + $_SERVER["REQUEST_URI"] ?? "/" +); diff --git a/public/servir-jaquette.php b/public/servir-jaquette.php new file mode 100644 index 0000000..bee21fc --- /dev/null +++ b/public/servir-jaquette.php @@ -0,0 +1,36 @@ + "image/jpeg", + "jpeg" => "image/jpeg", + "png" => "image/png", + "webp" => "image/webp", + "gif" => "image/gif" +]; + +header("Content-Type: " . ($types[$extension] ?? "application/octet-stream")); +header("Cache-Control: public, max-age=604800"); +readfile($chemin); diff --git a/src/Auth.php b/src/Auth.php new file mode 100644 index 0000000..d26fcf7 --- /dev/null +++ b/src/Auth.php @@ -0,0 +1,135 @@ + $duree, + "path" => "/", + "httponly" => true, + "samesite" => "Lax", + "secure" => false + ]); + + session_start(); + } + + public function connecter(array $utilisateur): void + { + session_regenerate_id(true); + $_SESSION[self::CLE_UTILISATEUR] = $utilisateur["id"]; + } + + public function deconnecter(): void + { + $_SESSION = []; + + if (ini_get("session.use_cookies")) + { + $params = session_get_cookie_params(); + setcookie( + session_name(), + "", + time() - 42000, + $params["path"], + $params["domain"] ?? "", + (bool) $params["secure"], + (bool) $params["httponly"] + ); + } + + session_destroy(); + } + + public function estConnecte(): bool + { + return $this->utilisateurCourant() !== null; + } + + public function utilisateurCourant(): ?array + { + $id = $_SESSION[self::CLE_UTILISATEUR] ?? null; + + if (!is_string($id) || $id === "") + { + return null; + } + + $utilisateur = $this->utilisateurService->trouverParId($id); + + if ($utilisateur === null || !($utilisateur["actif"] ?? false)) + { + return null; + } + + return $utilisateur; + } + + public function exigerConnexion(): array + { + $utilisateur = $this->utilisateurCourant(); + + if ($utilisateur === null) + { + Flash::definir("warning", "Veuillez vous connecter pour continuer."); + Helpers::rediriger("/connexion"); + } + + return $utilisateur; + } + + public function exigerAdmin(): array + { + $utilisateur = $this->exigerConnexion(); + + if (($utilisateur["role"] ?? "") !== "admin") + { + Flash::definir("danger", "Accès réservé aux administrateurs."); + Helpers::rediriger("/"); + } + + return $utilisateur; + } + + public function tenterConnexion(string $identifiant, string $motDePasse): bool + { + $utilisateur = $this->utilisateurService->trouverParIdentifiant($identifiant); + + if ($utilisateur === null) + { + return false; + } + + if (!($utilisateur["actif"] ?? false)) + { + return false; + } + + if (!password_verify($motDePasse, $utilisateur["motDePasseHash"])) + { + return false; + } + + $this->connecter($utilisateur); + + return true; + } +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..7232275 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,41 @@ + self::env("APP_NAME", "JeuxVideoTech"), + "appUrl" => self::env("APP_URL", "http://localhost:8080"), + "adminIdentifiant" => self::env("ADMIN_IDENTIFIANT", "admin"), + "adminMotDePasse" => self::env("ADMIN_MOT_DE_PASSE", "admin123"), + "adminEmail" => self::env("ADMIN_EMAIL", "glainey@planete-online.fr"), + "rawgApiKey" => self::env("RAWG_API_KEY", ""), + "sessionLifetime" => (int) self::env("SESSION_LIFETIME", "86400"), + "dataPath" => dirname(__DIR__) . "/data", + "jaquettesPath" => dirname(__DIR__) . "/data/jaquettes", + "cachePath" => dirname(__DIR__) . "/data/cache" + ]; + } + + public static function get(string $cle, mixed $defaut = null): mixed + { + return self::$valeurs[$cle] ?? $defaut; + } + + private static function env(string $cle, string $defaut = ""): string + { + $valeur = getenv($cle); + + if ($valeur === false || $valeur === "") + { + return $defaut; + } + + return $valeur; + } +} diff --git a/src/Controllers/AccueilController.php b/src/Controllers/AccueilController.php new file mode 100644 index 0000000..b6c5aeb --- /dev/null +++ b/src/Controllers/AccueilController.php @@ -0,0 +1,29 @@ +auth->exigerConnexion(); + $utilisateurId = $utilisateur["id"]; + + View::rendre("accueil", [ + "titrePage" => "Accueil", + "utilisateur" => $utilisateur, + "nombreConsoles" => $this->consoleService->compterPourUtilisateur($utilisateurId), + "nombreJeux" => $this->jeuService->compterPourUtilisateur($utilisateurId), + "derniersJeux" => $this->jeuService->derniersAjouts($utilisateurId), + "consoles" => $this->consoleService->listerPourUtilisateur($utilisateurId) + ]); + } +} diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php new file mode 100644 index 0000000..d3ec58b --- /dev/null +++ b/src/Controllers/AdminController.php @@ -0,0 +1,76 @@ +auth->exigerAdmin(); + + View::rendre("admin/utilisateurs", [ + "titrePage" => "Administration", + "utilisateur" => $admin, + "utilisateurs" => $this->utilisateurService->tout() + ]); + } + + public function basculerActif(string $id): void + { + $admin = $this->auth->exigerAdmin(); + Csrf::exiger(); + + if ($id === $admin["id"]) + { + Flash::definir("warning", "Vous ne pouvez pas désactiver votre propre compte."); + Helpers::rediriger("/admin/utilisateurs"); + } + + $cible = $this->utilisateurService->trouverParId($id); + + if ($cible === null) + { + Flash::definir("danger", "Utilisateur introuvable."); + Helpers::rediriger("/admin/utilisateurs"); + } + + $nouvelEtat = !($cible["actif"] ?? false); + $this->utilisateurService->definirActif($id, $nouvelEtat); + + Flash::definir( + "success", + $nouvelEtat ? "Compte activé." : "Compte désactivé." + ); + Helpers::rediriger("/admin/utilisateurs"); + } + + public function supprimer(string $id): void + { + $admin = $this->auth->exigerAdmin(); + Csrf::exiger(); + + if ($id === $admin["id"]) + { + Flash::definir("warning", "Vous ne pouvez pas supprimer votre propre compte."); + Helpers::rediriger("/admin/utilisateurs"); + } + + if ($this->utilisateurService->supprimer($id)) + { + Flash::definir("success", "Compte supprimé."); + } + else + { + Flash::definir("danger", "Utilisateur introuvable."); + } + + Helpers::rediriger("/admin/utilisateurs"); + } +} diff --git a/src/Controllers/AuthController.php b/src/Controllers/AuthController.php new file mode 100644 index 0000000..80273b1 --- /dev/null +++ b/src/Controllers/AuthController.php @@ -0,0 +1,91 @@ +auth->estConnecte()) + { + Helpers::rediriger("/"); + } + + if (Helpers::estPost()) + { + Csrf::exiger(); + + $identifiant = Helpers::champ("identifiant"); + $motDePasse = Helpers::champ("motDePasse"); + + if ($this->auth->tenterConnexion($identifiant, $motDePasse)) + { + Flash::definir("success", "Bienvenue !"); + Helpers::rediriger("/"); + } + + Flash::definir("danger", "Identifiant ou mot de passe incorrect."); + } + + View::rendre("auth/connexion", [ + "titrePage" => "Connexion", + "utilisateur" => null + ], "layout_auth"); + } + + public function inscription(): void + { + if ($this->auth->estConnecte()) + { + Helpers::rediriger("/"); + } + + if (Helpers::estPost()) + { + Csrf::exiger(); + + try + { + $utilisateur = $this->utilisateurService->creer( + Helpers::champ("identifiant"), + Helpers::champ("email"), + Helpers::champ("motDePasse"), + "utilisateur" + ); + + $this->auth->connecter($utilisateur); + Flash::definir("success", "Compte créé avec succès. Bienvenue !"); + Helpers::rediriger("/"); + } + catch (InvalidArgumentException $exception) + { + Flash::definir("danger", $exception->getMessage()); + } + } + + View::rendre("auth/inscription", [ + "titrePage" => "Inscription", + "utilisateur" => null + ], "layout_auth"); + } + + public function deconnexion(): void + { + if (Helpers::estPost()) + { + Csrf::exiger(); + } + + $this->auth->deconnecter(); + $this->auth->demarrerSession(); + Flash::definir("success", "Vous êtes déconnecté."); + Helpers::rediriger("/connexion"); + } +} diff --git a/src/Controllers/ConsoleController.php b/src/Controllers/ConsoleController.php new file mode 100644 index 0000000..093523b --- /dev/null +++ b/src/Controllers/ConsoleController.php @@ -0,0 +1,137 @@ +auth->exigerConnexion(); + $consoles = $this->consoleService->listerPourUtilisateur($utilisateur["id"]); + + $jeuxParConsole = []; + foreach ($consoles as $console) + { + $jeuxParConsole[$console["id"]] = count( + $this->jeuService->listerParConsole($console["id"], $utilisateur["id"]) + ); + } + + View::rendre("consoles/liste", [ + "titrePage" => "Mes consoles", + "utilisateur" => $utilisateur, + "consoles" => $consoles, + "jeuxParConsole" => $jeuxParConsole + ]); + } + + public function detail(string $id): void + { + $utilisateur = $this->auth->exigerConnexion(); + $console = $this->consoleService->trouverPourUtilisateur($id, $utilisateur["id"]); + + if ($console === null) + { + Flash::definir("danger", "Console introuvable."); + Helpers::rediriger("/consoles"); + } + + View::rendre("consoles/detail", [ + "titrePage" => $console["nom"], + "utilisateur" => $utilisateur, + "console" => $console, + "jeux" => $this->jeuService->listerParConsole($id, $utilisateur["id"]) + ]); + } + + public function creer(): void + { + $utilisateur = $this->auth->exigerConnexion(); + + if (Helpers::estPost()) + { + Csrf::exiger(); + + try + { + $console = $this->consoleService->creer($utilisateur["id"], $_POST); + Flash::definir("success", "Console ajoutée."); + Helpers::rediriger("/consoles/" . $console["id"]); + } + catch (InvalidArgumentException $exception) + { + Flash::definir("danger", $exception->getMessage()); + } + } + + View::rendre("consoles/formulaire", [ + "titrePage" => "Ajouter une console", + "utilisateur" => $utilisateur, + "console" => null, + "action" => "/consoles/creer" + ]); + } + + public function modifier(string $id): void + { + $utilisateur = $this->auth->exigerConnexion(); + $console = $this->consoleService->trouverPourUtilisateur($id, $utilisateur["id"]); + + if ($console === null) + { + Flash::definir("danger", "Console introuvable."); + Helpers::rediriger("/consoles"); + } + + if (Helpers::estPost()) + { + Csrf::exiger(); + + try + { + $console = $this->consoleService->modifier($id, $utilisateur["id"], $_POST); + Flash::definir("success", "Console mise à jour."); + Helpers::rediriger("/consoles/" . $console["id"]); + } + catch (InvalidArgumentException $exception) + { + Flash::definir("danger", $exception->getMessage()); + } + } + + View::rendre("consoles/formulaire", [ + "titrePage" => "Modifier " . $console["nom"], + "utilisateur" => $utilisateur, + "console" => $console, + "action" => "/consoles/" . $id . "/modifier" + ]); + } + + public function supprimer(string $id): void + { + $utilisateur = $this->auth->exigerConnexion(); + Csrf::exiger(); + + $console = $this->consoleService->trouverPourUtilisateur($id, $utilisateur["id"]); + + if ($console === null) + { + Flash::definir("danger", "Console introuvable."); + Helpers::rediriger("/consoles"); + } + + $this->jeuService->supprimerParConsole($id, $utilisateur["id"]); + $this->consoleService->supprimer($id, $utilisateur["id"]); + + Flash::definir("success", "Console et jeux associés supprimés."); + Helpers::rediriger("/consoles"); + } +} diff --git a/src/Controllers/JeuController.php b/src/Controllers/JeuController.php new file mode 100644 index 0000000..da80610 --- /dev/null +++ b/src/Controllers/JeuController.php @@ -0,0 +1,267 @@ +auth->exigerConnexion(); + $recherche = Helpers::champ("q"); + $consoleId = Helpers::champ("console"); + $etat = Helpers::champ("etat"); + + $jeux = $this->jeuService->listerPourUtilisateur( + $utilisateur["id"], + $recherche !== "" ? $recherche : null, + $consoleId !== "" ? $consoleId : null, + $etat !== "" ? $etat : null + ); + + $consoles = $this->consoleService->listerPourUtilisateur($utilisateur["id"]); + $consolesParId = []; + + foreach ($consoles as $console) + { + $consolesParId[$console["id"]] = $console; + } + + View::rendre("jeux/liste", [ + "titrePage" => "Mes jeux", + "utilisateur" => $utilisateur, + "jeux" => $jeux, + "consoles" => $consoles, + "consolesParId" => $consolesParId, + "filtres" => [ + "q" => $recherche, + "console" => $consoleId, + "etat" => $etat + ] + ]); + } + + public function detail(string $id): void + { + $utilisateur = $this->auth->exigerConnexion(); + $jeu = $this->jeuService->trouverPourUtilisateur($id, $utilisateur["id"]); + + if ($jeu === null) + { + Flash::definir("danger", "Jeu introuvable."); + Helpers::rediriger("/jeux"); + } + + $console = $this->consoleService->trouverPourUtilisateur( + $jeu["consoleId"], + $utilisateur["id"] + ); + + View::rendre("jeux/detail", [ + "titrePage" => $jeu["titre"], + "utilisateur" => $utilisateur, + "jeu" => $jeu, + "console" => $console + ]); + } + + public function creer(): void + { + $utilisateur = $this->auth->exigerConnexion(); + $consoles = $this->consoleService->listerPourUtilisateur($utilisateur["id"]); + + if ($consoles === []) + { + Flash::definir("warning", "Ajoutez d'abord une console avant d'enregistrer un jeu."); + Helpers::rediriger("/consoles/creer"); + } + + $prefill = $this->construirePrefillDepuisGet(); + + if (Helpers::estPost()) + { + Csrf::exiger(); + + try + { + $donnees = $this->collecterDonneesFormulaire(); + $jeu = $this->jeuService->creer($utilisateur["id"], $donnees); + Flash::definir("success", "Jeu ajouté à votre bibliothèque."); + Helpers::rediriger("/jeux/" . $jeu["id"]); + } + catch (InvalidArgumentException $exception) + { + Flash::definir("danger", $exception->getMessage()); + $prefill = array_merge($prefill, $_POST); + } + } + + View::rendre("jeux/formulaire", [ + "titrePage" => "Ajouter un jeu", + "utilisateur" => $utilisateur, + "jeu" => $prefill, + "consoles" => $consoles, + "action" => "/jeux/creer", + "apiDisponible" => $this->enrichissementService->estDisponible() + ]); + } + + public function modifier(string $id): void + { + $utilisateur = $this->auth->exigerConnexion(); + $jeu = $this->jeuService->trouverPourUtilisateur($id, $utilisateur["id"]); + + if ($jeu === null) + { + Flash::definir("danger", "Jeu introuvable."); + Helpers::rediriger("/jeux"); + } + + $consoles = $this->consoleService->listerPourUtilisateur($utilisateur["id"]); + + if (Helpers::estPost()) + { + Csrf::exiger(); + + try + { + $donnees = $this->collecterDonneesFormulaire(); + $jeu = $this->jeuService->modifier($id, $utilisateur["id"], $donnees); + Flash::definir("success", "Jeu mis à jour."); + Helpers::rediriger("/jeux/" . $jeu["id"]); + } + catch (InvalidArgumentException $exception) + { + Flash::definir("danger", $exception->getMessage()); + $jeu = array_merge($jeu, $_POST); + } + } + + View::rendre("jeux/formulaire", [ + "titrePage" => "Modifier " . $jeu["titre"], + "utilisateur" => $utilisateur, + "jeu" => $jeu, + "consoles" => $consoles, + "action" => "/jeux/" . $id . "/modifier", + "apiDisponible" => $this->enrichissementService->estDisponible() + ]); + } + + public function supprimer(string $id): void + { + $utilisateur = $this->auth->exigerConnexion(); + Csrf::exiger(); + + if ($this->jeuService->supprimer($id, $utilisateur["id"])) + { + Flash::definir("success", "Jeu supprimé."); + } + else + { + Flash::definir("danger", "Jeu introuvable."); + } + + Helpers::rediriger("/jeux"); + } + + public function rechercherApi(): void + { + $this->auth->exigerConnexion(); + + header("Content-Type: application/json; charset=utf-8"); + + $titre = Helpers::champ("q"); + + if ($titre === "") + { + print json_encode(["resultats" => []]); + return; + } + + $resultats = $this->enrichissementService->rechercher($titre); + print json_encode(["resultats" => $resultats], JSON_UNESCAPED_UNICODE); + } + + public function appliquerEnrichissement(): void + { + $utilisateur = $this->auth->exigerConnexion(); + Csrf::exiger(); + + $rawgId = (int) Helpers::champ("rawgId"); + $retour = Helpers::champ("retour", "/jeux/creer"); + + if ($rawgId <= 0) + { + Flash::definir("danger", "Sélection d'enrichissement invalide."); + Helpers::rediriger($retour); + } + + $detail = $this->enrichissementService->detail($rawgId); + + if ($detail === null) + { + Flash::definir("warning", "Impossible de récupérer les métadonnées. Saisie manuelle possible."); + Helpers::rediriger($retour); + } + + $jaquette = $this->enrichissementService->telechargerJaquette($detail["jaquetteUrl"] ?? null); + + $query = http_build_query([ + "titre" => $detail["titre"] ?? "", + "description" => $detail["description"] ?? "", + "dateSortie" => $detail["dateSortie"] ?? "", + "genres" => implode(", ", $detail["genres"] ?? []), + "editeur" => $detail["editeur"] ?? "", + "developpeur" => $detail["developpeur"] ?? "", + "jaquette" => $jaquette ?? "", + "sourceEnrichissement" => "api" + ]); + + $separateur = str_contains($retour, "?") ? "&" : "?"; + Helpers::rediriger($retour . $separateur . $query); + } + + private function collecterDonneesFormulaire(): array + { + return [ + "titre" => Helpers::champ("titre"), + "consoleId" => Helpers::champ("consoleId"), + "etat" => Helpers::champ("etat", "possede"), + "jaquette" => Helpers::champ("jaquette"), + "description" => Helpers::champ("description"), + "dateSortie" => Helpers::champ("dateSortie"), + "genres" => Helpers::champ("genres"), + "editeur" => Helpers::champ("editeur"), + "developpeur" => Helpers::champ("developpeur"), + "notes" => Helpers::champ("notes"), + "sourceEnrichissement" => Helpers::champ("sourceEnrichissement", "manuel") + ]; + } + + private function construirePrefillDepuisGet(): array + { + $genres = Helpers::champ("genres"); + + return [ + "titre" => Helpers::champ("titre"), + "consoleId" => Helpers::champ("consoleId"), + "etat" => Helpers::champ("etat", "possede"), + "jaquette" => Helpers::champ("jaquette"), + "description" => Helpers::champ("description"), + "dateSortie" => Helpers::champ("dateSortie"), + "genres" => $genres !== "" ? array_map("trim", explode(",", $genres)) : [], + "editeur" => Helpers::champ("editeur"), + "developpeur" => Helpers::champ("developpeur"), + "notes" => Helpers::champ("notes"), + "sourceEnrichissement" => Helpers::champ("sourceEnrichissement", "manuel") + ]; + } +} diff --git a/src/Csrf.php b/src/Csrf.php new file mode 100644 index 0000000..9170c5c --- /dev/null +++ b/src/Csrf.php @@ -0,0 +1,46 @@ +'; + } + + public static function verifier(?string $token): bool + { + if ($token === null || empty($_SESSION[self::CLE_SESSION])) + { + return false; + } + + return hash_equals($_SESSION[self::CLE_SESSION], $token); + } + + public static function exiger(): void + { + $token = $_POST["_csrf"] ?? null; + + if (!self::verifier(is_string($token) ? $token : null)) + { + http_response_code(403); + exit("Requête refusée : jeton CSRF invalide."); + } + } +} diff --git a/src/Flash.php b/src/Flash.php new file mode 100644 index 0000000..593e3f4 --- /dev/null +++ b/src/Flash.php @@ -0,0 +1,29 @@ + $type, + "message" => $message + ]; + } + + public static function consommer(): ?array + { + if (empty($_SESSION[self::CLE])) + { + return null; + } + + $flash = $_SESSION[self::CLE]; + unset($_SESSION[self::CLE]); + + return $flash; + } +} diff --git a/src/Helpers.php b/src/Helpers.php new file mode 100644 index 0000000..eae2c67 --- /dev/null +++ b/src/Helpers.php @@ -0,0 +1,79 @@ + "Possédé", + "a_acheter" => "À acheter", + "prete" => "Prêté", + "vendu" => "Vendu" + ]; + } + + public static function libelleEtat(string $etat): string + { + return self::etatsJeu()[$etat] ?? $etat; + } + + public static function urlJaquette(?string $chemin): string + { + if ($chemin === null || $chemin === "") + { + return "/assets/img/jaquette-manquante.svg"; + } + + if (str_starts_with($chemin, "http://") || str_starts_with($chemin, "https://")) + { + return $chemin; + } + + return "/jaquettes/" . ltrim($chemin, "/"); + } + + public static function tronquer(string $texte, int $longueur = 140): string + { + if (mb_strlen($texte) <= $longueur) + { + return $texte; + } + + return mb_substr($texte, 0, $longueur - 1) . "…"; + } +} diff --git a/src/JsonStore.php b/src/JsonStore.php new file mode 100644 index 0000000..4334c27 --- /dev/null +++ b/src/JsonStore.php @@ -0,0 +1,162 @@ +cheminFichier = $cheminFichier; + $this->assurerFichier(); + } + + public function tout(): array + { + $contenu = file_get_contents($this->cheminFichier); + + if ($contenu === false || trim($contenu) === "") + { + return []; + } + + $donnees = json_decode($contenu, true); + + return is_array($donnees) ? $donnees : []; + } + + public function trouverParId(string $id): ?array + { + foreach ($this->tout() as $element) + { + if (($element["id"] ?? null) === $id) + { + return $element; + } + } + + return null; + } + + public function filtrer(callable $filtre): array + { + return array_values(array_filter($this->tout(), $filtre)); + } + + public function ajouter(array $element): array + { + return $this->avecVerrou(function (array &$donnees) use ($element): array + { + $donnees[] = $element; + + return $element; + }); + } + + public function mettreAJour(string $id, array $nouveauxChamps): ?array + { + return $this->avecVerrou(function (array &$donnees) use ($id, $nouveauxChamps): ?array + { + foreach ($donnees as $index => $element) + { + if (($element["id"] ?? null) !== $id) + { + continue; + } + + $donnees[$index] = array_merge($element, $nouveauxChamps); + + return $donnees[$index]; + } + + return null; + }); + } + + public function supprimer(string $id): bool + { + return $this->avecVerrou(function (array &$donnees) use ($id): bool + { + $tailleInitiale = count($donnees); + $donnees = array_values(array_filter( + $donnees, + static fn (array $element): bool => ($element["id"] ?? null) !== $id + )); + + return count($donnees) !== $tailleInitiale; + }); + } + + public function remplacerTout(array $donnees): void + { + $this->avecVerrou(function (array &$existantes) use ($donnees): null + { + $existantes = $donnees; + + return null; + }); + } + + private function assurerFichier(): void + { + $dossier = dirname($this->cheminFichier); + + if (!is_dir($dossier)) + { + mkdir($dossier, 0775, true); + } + + if (!file_exists($this->cheminFichier)) + { + file_put_contents($this->cheminFichier, "[]" . PHP_EOL, LOCK_EX); + } + } + + private function avecVerrou(callable $callback): mixed + { + $handle = fopen($this->cheminFichier, "c+"); + + if ($handle === false) + { + throw new RuntimeException("Impossible d'ouvrir " . $this->cheminFichier); + } + + try + { + flock($handle, LOCK_EX); + rewind($handle); + $contenu = stream_get_contents($handle); + $donnees = []; + + if ($contenu !== false && trim($contenu) !== "") + { + $decode = json_decode($contenu, true); + $donnees = is_array($decode) ? $decode : []; + } + + $resultat = $callback($donnees); + + $json = json_encode( + $donnees, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, $json . PHP_EOL); + fflush($handle); + + return $resultat; + } + finally + { + flock($handle, LOCK_UN); + fclose($handle); + } + } +} diff --git a/src/Router.php b/src/Router.php new file mode 100644 index 0000000..b9b9478 --- /dev/null +++ b/src/Router.php @@ -0,0 +1,66 @@ +ajouter("GET", $chemin, $action); + } + + public function post(string $chemin, callable $action): void + { + $this->ajouter("POST", $chemin, $action); + } + + public function dispatcher(string $methode, string $uri): void + { + $chemin = parse_url($uri, PHP_URL_PATH) ?: "/"; + $chemin = rtrim($chemin, "/") ?: "/"; + + foreach ($this->routes as $route) + { + if ($route["methode"] !== strtoupper($methode)) + { + continue; + } + + $motif = "@^" . $route["motif"] . "$@"; + + if (!preg_match($motif, $chemin, $captures)) + { + continue; + } + + $params = array_filter( + $captures, + static fn ($cle): bool => !is_int($cle), + ARRAY_FILTER_USE_KEY + ); + + call_user_func_array($route["action"], array_values($params)); + return; + } + + http_response_code(404); + View::rendre("erreurs/404", [ + "titrePage" => "Page introuvable", + "utilisateur" => null + ], "layout_auth"); + } + + private function ajouter(string $methode, string $chemin, callable $action): void + { + $motif = preg_replace("#\{([a-zA-Z_]+)\}#", "(?P<$1>[^/]+)", $chemin); + $motif = str_replace("/", "\/", $motif ?? $chemin); + + $this->routes[] = [ + "methode" => $methode, + "motif" => $motif, + "action" => $action + ]; + } +} diff --git a/src/Services/ConsoleService.php b/src/Services/ConsoleService.php new file mode 100644 index 0000000..95f5bbc --- /dev/null +++ b/src/Services/ConsoleService.php @@ -0,0 +1,133 @@ +store = new JsonStore(Config::get("dataPath") . "/consoles.json"); + } + + public function listerPourUtilisateur(string $utilisateurId): array + { + $consoles = $this->store->filtrer( + static fn (array $console): bool => ($console["utilisateurId"] ?? "") === $utilisateurId + ); + + usort( + $consoles, + static fn (array $a, array $b): int => strcmp($a["nom"] ?? "", $b["nom"] ?? "") + ); + + return $consoles; + } + + public function trouverPourUtilisateur(string $id, string $utilisateurId): ?array + { + $console = $this->store->trouverParId($id); + + if ($console === null || ($console["utilisateurId"] ?? "") !== $utilisateurId) + { + return null; + } + + return $console; + } + + public function creer(string $utilisateurId, array $donnees): array + { + $nom = trim((string) ($donnees["nom"] ?? "")); + $constructeur = trim((string) ($donnees["constructeur"] ?? "")); + + if ($nom === "" || $constructeur === "") + { + throw new InvalidArgumentException("Le nom et le constructeur sont obligatoires."); + } + + $annee = $this->normaliserAnnee($donnees["annee"] ?? null); + $maintenant = Helpers::maintenant(); + + $console = [ + "id" => Helpers::genererId(), + "utilisateurId" => $utilisateurId, + "nom" => $nom, + "constructeur" => $constructeur, + "annee" => $annee, + "notes" => trim((string) ($donnees["notes"] ?? "")), + "createdAt" => $maintenant, + "updatedAt" => $maintenant + ]; + + return $this->store->ajouter($console); + } + + public function modifier(string $id, string $utilisateurId, array $donnees): array + { + $console = $this->trouverPourUtilisateur($id, $utilisateurId); + + if ($console === null) + { + throw new RuntimeException("Console introuvable."); + } + + $nom = trim((string) ($donnees["nom"] ?? "")); + $constructeur = trim((string) ($donnees["constructeur"] ?? "")); + + if ($nom === "" || $constructeur === "") + { + throw new InvalidArgumentException("Le nom et le constructeur sont obligatoires."); + } + + $miseAJour = $this->store->mettreAJour($id, [ + "nom" => $nom, + "constructeur" => $constructeur, + "annee" => $this->normaliserAnnee($donnees["annee"] ?? null), + "notes" => trim((string) ($donnees["notes"] ?? "")), + "updatedAt" => Helpers::maintenant() + ]); + + if ($miseAJour === null) + { + throw new RuntimeException("Échec de la mise à jour."); + } + + return $miseAJour; + } + + public function supprimer(string $id, string $utilisateurId): bool + { + $console = $this->trouverPourUtilisateur($id, $utilisateurId); + + if ($console === null) + { + return false; + } + + return $this->store->supprimer($id); + } + + public function compterPourUtilisateur(string $utilisateurId): int + { + return count($this->listerPourUtilisateur($utilisateurId)); + } + + private function normaliserAnnee(mixed $annee): ?int + { + if ($annee === null || $annee === "") + { + return null; + } + + $valeur = (int) $annee; + + if ($valeur < 1970 || $valeur > (int) date("Y") + 2) + { + return null; + } + + return $valeur; + } +} diff --git a/src/Services/EnrichissementService.php b/src/Services/EnrichissementService.php new file mode 100644 index 0000000..acd1685 --- /dev/null +++ b/src/Services/EnrichissementService.php @@ -0,0 +1,250 @@ +estDisponible()) + { + return []; + } + + $cacheCle = "recherche_" . md5(mb_strtolower($titre)); + $cache = $this->lireCache($cacheCle); + + if ($cache !== null) + { + return array_slice($cache, 0, $limite); + } + + $reponse = $this->appeler("/games", [ + "search" => $titre, + "page_size" => $limite + ]); + + if ($reponse === null || !isset($reponse["results"]) || !is_array($reponse["results"])) + { + return []; + } + + $resultats = []; + + foreach ($reponse["results"] as $jeu) + { + $resultats[] = $this->mapperResultatListe($jeu); + } + + $this->ecrireCache($cacheCle, $resultats); + + return $resultats; + } + + public function detail(int $rawgId): ?array + { + if (!$this->estDisponible()) + { + return null; + } + + $cacheCle = "detail_" . $rawgId; + $cache = $this->lireCache($cacheCle); + + if ($cache !== null) + { + return $cache; + } + + $reponse = $this->appeler("/games/" . $rawgId); + + if ($reponse === null) + { + return null; + } + + $detail = $this->mapperDetail($reponse); + $this->ecrireCache($cacheCle, $detail); + + return $detail; + } + + public function telechargerJaquette(?string $urlImage): ?string + { + if ($urlImage === null || $urlImage === "") + { + return null; + } + + $extension = pathinfo(parse_url($urlImage, PHP_URL_PATH) ?? "", PATHINFO_EXTENSION); + $extension = $extension !== "" ? strtolower($extension) : "jpg"; + + if (!in_array($extension, ["jpg", "jpeg", "png", "webp", "gif"], true)) + { + $extension = "jpg"; + } + + $nomFichier = Helpers::genererId() . "." . $extension; + $cheminLocal = Config::get("jaquettesPath") . "/" . $nomFichier; + + $contexte = stream_context_create([ + "http" => [ + "timeout" => 10, + "header" => "User-Agent: JeuxVideoTech/1.0\r\n" + ] + ]); + + $contenu = @file_get_contents($urlImage, false, $contexte); + + if ($contenu === false) + { + return $urlImage; + } + + if (file_put_contents($cheminLocal, $contenu) === false) + { + return $urlImage; + } + + return $nomFichier; + } + + private function mapperResultatListe(array $jeu): array + { + $genres = []; + + foreach ($jeu["genres"] ?? [] as $genre) + { + if (!empty($genre["name"])) + { + $genres[] = $genre["name"]; + } + } + + return [ + "rawgId" => (int) ($jeu["id"] ?? 0), + "titre" => (string) ($jeu["name"] ?? ""), + "dateSortie" => $jeu["released"] ?? null, + "jaquetteUrl" => $jeu["background_image"] ?? null, + "genres" => $genres, + "note" => $jeu["rating"] ?? null + ]; + } + + private function mapperDetail(array $jeu): array + { + $base = $this->mapperResultatListe($jeu); + + $editeurs = []; + foreach ($jeu["publishers"] ?? [] as $editeur) + { + if (!empty($editeur["name"])) + { + $editeurs[] = $editeur["name"]; + } + } + + $developpeurs = []; + foreach ($jeu["developers"] ?? [] as $developpeur) + { + if (!empty($developpeur["name"])) + { + $developpeurs[] = $developpeur["name"]; + } + } + + $description = (string) ($jeu["description_raw"] ?? ""); + + if ($description === "" && !empty($jeu["description"])) + { + $description = trim(strip_tags((string) $jeu["description"])); + } + + return array_merge($base, [ + "description" => $description, + "editeur" => implode(", ", $editeurs), + "developpeur" => implode(", ", $developpeurs) + ]); + } + + private function appeler(string $chemin, array $params = []): ?array + { + $params["key"] = Config::get("rawgApiKey"); + $url = self::BASE_URL . $chemin . "?" . http_build_query($params); + + $contexte = stream_context_create([ + "http" => [ + "method" => "GET", + "timeout" => 8, + "header" => "User-Agent: JeuxVideoTech/1.0\r\nAccept: application/json\r\n" + ] + ]); + + $reponse = @file_get_contents($url, false, $contexte); + + if ($reponse === false) + { + return null; + } + + $donnees = json_decode($reponse, true); + + return is_array($donnees) ? $donnees : null; + } + + private function lireCache(string $cle): ?array + { + $fichier = Config::get("cachePath") . "/" . $cle . ".json"; + + if (!is_file($fichier)) + { + return null; + } + + if (filemtime($fichier) < time() - 86400) + { + return null; + } + + $contenu = file_get_contents($fichier); + + if ($contenu === false) + { + return null; + } + + $donnees = json_decode($contenu, true); + + return is_array($donnees) ? $donnees : null; + } + + private function ecrireCache(string $cle, array $donnees): void + { + $dossier = Config::get("cachePath"); + + if (!is_dir($dossier)) + { + mkdir($dossier, 0775, true); + } + + $fichier = $dossier . "/" . $cle . ".json"; + file_put_contents( + $fichier, + json_encode($donnees, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + LOCK_EX + ); + } +} diff --git a/src/Services/JeuService.php b/src/Services/JeuService.php new file mode 100644 index 0000000..ba00eb1 --- /dev/null +++ b/src/Services/JeuService.php @@ -0,0 +1,249 @@ +store = new JsonStore(Config::get("dataPath") . "/jeux.json"); + } + + public function listerPourUtilisateur( + string $utilisateurId, + ?string $recherche = null, + ?string $consoleId = null, + ?string $etat = null + ): array + { + $jeux = $this->store->filtrer( + static fn (array $jeu): bool => ($jeu["utilisateurId"] ?? "") === $utilisateurId + ); + + if ($consoleId !== null && $consoleId !== "") + { + $jeux = array_values(array_filter( + $jeux, + static fn (array $jeu): bool => ($jeu["consoleId"] ?? "") === $consoleId + )); + } + + if ($etat !== null && $etat !== "") + { + $jeux = array_values(array_filter( + $jeux, + static fn (array $jeu): bool => ($jeu["etat"] ?? "") === $etat + )); + } + + if ($recherche !== null && $recherche !== "") + { + $terme = mb_strtolower($recherche); + $jeux = array_values(array_filter( + $jeux, + static function (array $jeu) use ($terme): bool + { + $champs = [ + $jeu["titre"] ?? "", + $jeu["description"] ?? "", + $jeu["editeur"] ?? "", + $jeu["developpeur"] ?? "", + implode(" ", $jeu["genres"] ?? []) + ]; + + return str_contains(mb_strtolower(implode(" ", $champs)), $terme); + } + )); + } + + usort( + $jeux, + static fn (array $a, array $b): int => strcmp($a["titre"] ?? "", $b["titre"] ?? "") + ); + + return $jeux; + } + + public function trouverPourUtilisateur(string $id, string $utilisateurId): ?array + { + $jeu = $this->store->trouverParId($id); + + if ($jeu === null || ($jeu["utilisateurId"] ?? "") !== $utilisateurId) + { + return null; + } + + return $jeu; + } + + public function listerParConsole(string $consoleId, string $utilisateurId): array + { + return $this->listerPourUtilisateur($utilisateurId, null, $consoleId, null); + } + + public function derniersAjouts(string $utilisateurId, int $limite = 6): array + { + $jeux = $this->listerPourUtilisateur($utilisateurId); + + usort( + $jeux, + static fn (array $a, array $b): int => strcmp($b["createdAt"] ?? "", $a["createdAt"] ?? "") + ); + + return array_slice($jeux, 0, $limite); + } + + public function creer(string $utilisateurId, array $donnees): array + { + $jeu = $this->preparerDonnees($utilisateurId, $donnees); + $maintenant = Helpers::maintenant(); + + $jeu["id"] = Helpers::genererId(); + $jeu["utilisateurId"] = $utilisateurId; + $jeu["createdAt"] = $maintenant; + $jeu["updatedAt"] = $maintenant; + + return $this->store->ajouter($jeu); + } + + public function modifier(string $id, string $utilisateurId, array $donnees): array + { + $existant = $this->trouverPourUtilisateur($id, $utilisateurId); + + if ($existant === null) + { + throw new RuntimeException("Jeu introuvable."); + } + + $jeu = $this->preparerDonnees($utilisateurId, $donnees, $existant); + $jeu["updatedAt"] = Helpers::maintenant(); + + $miseAJour = $this->store->mettreAJour($id, $jeu); + + if ($miseAJour === null) + { + throw new RuntimeException("Échec de la mise à jour."); + } + + return $miseAJour; + } + + public function supprimer(string $id, string $utilisateurId): bool + { + $jeu = $this->trouverPourUtilisateur($id, $utilisateurId); + + if ($jeu === null) + { + return false; + } + + $this->supprimerJaquetteLocale($jeu["jaquette"] ?? null); + + return $this->store->supprimer($id); + } + + public function compterPourUtilisateur(string $utilisateurId): int + { + return count($this->listerPourUtilisateur($utilisateurId)); + } + + public function supprimerParConsole(string $consoleId, string $utilisateurId): int + { + $jeux = $this->listerParConsole($consoleId, $utilisateurId); + $compteur = 0; + + foreach ($jeux as $jeu) + { + if ($this->supprimer($jeu["id"], $utilisateurId)) + { + $compteur++; + } + } + + return $compteur; + } + + private function preparerDonnees(string $utilisateurId, array $donnees, ?array $existant = null): array + { + $titre = trim((string) ($donnees["titre"] ?? "")); + $consoleId = trim((string) ($donnees["consoleId"] ?? "")); + $etat = trim((string) ($donnees["etat"] ?? "possede")); + + if ($titre === "" || $consoleId === "") + { + throw new InvalidArgumentException("Le titre et la console sont obligatoires."); + } + + if (!array_key_exists($etat, Helpers::etatsJeu())) + { + throw new InvalidArgumentException("État de jeu invalide."); + } + + $genres = $donnees["genres"] ?? []; + + if (is_string($genres)) + { + $genres = array_values(array_filter(array_map("trim", explode(",", $genres)))); + } + + if (!is_array($genres)) + { + $genres = []; + } + + $jaquette = trim((string) ($donnees["jaquette"] ?? ($existant["jaquette"] ?? ""))); + + return [ + "titre" => $titre, + "consoleId" => $consoleId, + "etat" => $etat, + "jaquette" => $jaquette, + "description" => trim((string) ($donnees["description"] ?? "")), + "dateSortie" => $this->normaliserDate($donnees["dateSortie"] ?? null), + "genres" => array_values($genres), + "editeur" => trim((string) ($donnees["editeur"] ?? "")), + "developpeur" => trim((string) ($donnees["developpeur"] ?? "")), + "notes" => trim((string) ($donnees["notes"] ?? "")), + "sourceEnrichissement" => trim((string) ($donnees["sourceEnrichissement"] ?? "manuel")) + ]; + } + + private function normaliserDate(mixed $date): ?string + { + if ($date === null || $date === "") + { + return null; + } + + $date = (string) $date; + + if (!preg_match("/^\d{4}-\d{2}-\d{2}$/", $date)) + { + return null; + } + + return $date; + } + + private function supprimerJaquetteLocale(?string $chemin): void + { + if ($chemin === null || $chemin === "") + { + return; + } + + if (str_starts_with($chemin, "http://") || str_starts_with($chemin, "https://")) + { + return; + } + + $fichier = Config::get("jaquettesPath") . "/" . basename($chemin); + + if (is_file($fichier)) + { + unlink($fichier); + } + } +} diff --git a/src/Services/UtilisateurService.php b/src/Services/UtilisateurService.php new file mode 100644 index 0000000..12dca5c --- /dev/null +++ b/src/Services/UtilisateurService.php @@ -0,0 +1,108 @@ +store = new JsonStore(Config::get("dataPath") . "/utilisateurs.json"); + } + + public function tout(): array + { + $utilisateurs = $this->store->tout(); + usort( + $utilisateurs, + static fn (array $a, array $b): int => strcmp($a["identifiant"] ?? "", $b["identifiant"] ?? "") + ); + + return $utilisateurs; + } + + public function trouverParId(string $id): ?array + { + return $this->store->trouverParId($id); + } + + public function trouverParIdentifiant(string $identifiant): ?array + { + $identifiant = mb_strtolower(trim($identifiant)); + + foreach ($this->store->tout() as $utilisateur) + { + if (mb_strtolower($utilisateur["identifiant"] ?? "") === $identifiant) + { + return $utilisateur; + } + } + + return null; + } + + public function creer(string $identifiant, string $email, string $motDePasse, string $role = "utilisateur"): array + { + $identifiant = trim($identifiant); + $email = trim($email); + + if ($identifiant === "" || $motDePasse === "") + { + throw new InvalidArgumentException("Identifiant et mot de passe obligatoires."); + } + + if ($this->trouverParIdentifiant($identifiant) !== null) + { + throw new InvalidArgumentException("Cet identifiant est déjà utilisé."); + } + + if (strlen($motDePasse) < 6) + { + throw new InvalidArgumentException("Le mot de passe doit contenir au moins 6 caractères."); + } + + $maintenant = Helpers::maintenant(); + + $utilisateur = [ + "id" => Helpers::genererId(), + "identifiant" => $identifiant, + "email" => $email, + "motDePasseHash" => password_hash($motDePasse, PASSWORD_DEFAULT), + "role" => $role === "admin" ? "admin" : "utilisateur", + "actif" => true, + "createdAt" => $maintenant, + "updatedAt" => $maintenant + ]; + + return $this->store->ajouter($utilisateur); + } + + public function definirActif(string $id, bool $actif): ?array + { + return $this->store->mettreAJour($id, [ + "actif" => $actif, + "updatedAt" => Helpers::maintenant() + ]); + } + + public function supprimer(string $id): bool + { + return $this->store->supprimer($id); + } + + public function assurerCompteAdmin(): void + { + if (count($this->store->tout()) > 0) + { + return; + } + + $this->creer( + Config::get("adminIdentifiant"), + Config::get("adminEmail"), + Config::get("adminMotDePasse"), + "admin" + ); + } +} diff --git a/src/View.php b/src/View.php new file mode 100644 index 0000000..9b06ef8 --- /dev/null +++ b/src/View.php @@ -0,0 +1,31 @@ +demarrerSession(); +$utilisateurService->assurerCompteAdmin(); + +$authController = new AuthController($auth, $utilisateurService); +$accueilController = new AccueilController($auth, $consoleService, $jeuService); +$consoleController = new ConsoleController($auth, $consoleService, $jeuService); +$jeuController = new JeuController($auth, $jeuService, $consoleService, $enrichissementService); +$adminController = new AdminController($auth, $utilisateurService); + +$router = new Router(); + +$router->get("/", [$accueilController, "index"]); + +$router->get("/connexion", [$authController, "connexion"]); +$router->post("/connexion", [$authController, "connexion"]); +$router->get("/inscription", [$authController, "inscription"]); +$router->post("/inscription", [$authController, "inscription"]); +$router->post("/deconnexion", [$authController, "deconnexion"]); + +$router->get("/consoles", [$consoleController, "liste"]); +$router->get("/consoles/creer", [$consoleController, "creer"]); +$router->post("/consoles/creer", [$consoleController, "creer"]); +$router->get("/consoles/{id}", [$consoleController, "detail"]); +$router->get("/consoles/{id}/modifier", [$consoleController, "modifier"]); +$router->post("/consoles/{id}/modifier", [$consoleController, "modifier"]); +$router->post("/consoles/{id}/supprimer", [$consoleController, "supprimer"]); + +$router->get("/jeux", [$jeuController, "liste"]); +$router->get("/jeux/creer", [$jeuController, "creer"]); +$router->post("/jeux/creer", [$jeuController, "creer"]); +$router->get("/jeux/api/rechercher", [$jeuController, "rechercherApi"]); +$router->post("/jeux/api/appliquer", [$jeuController, "appliquerEnrichissement"]); +$router->get("/jeux/{id}", [$jeuController, "detail"]); +$router->get("/jeux/{id}/modifier", [$jeuController, "modifier"]); +$router->post("/jeux/{id}/modifier", [$jeuController, "modifier"]); +$router->post("/jeux/{id}/supprimer", [$jeuController, "supprimer"]); + +$router->get("/admin/utilisateurs", [$adminController, "index"]); +$router->post("/admin/utilisateurs/{id}/basculer", [$adminController, "basculerActif"]); +$router->post("/admin/utilisateurs/{id}/supprimer", [$adminController, "supprimer"]); + +return $router; diff --git a/templates/accueil.php b/templates/accueil.php new file mode 100644 index 0000000..f883ed6 --- /dev/null +++ b/templates/accueil.php @@ -0,0 +1,71 @@ + + +
+
+ Consoles + +
+
+ Jeux + +
+
+ +
+
+

Derniers ajouts

+ Voir tous les jeux +
+ + +
+ +

Aucun jeu pour le moment. Commencez par ajouter une console, puis un jeu.

+ Ajouter un jeu +
+ + + +
diff --git a/templates/admin/utilisateurs.php b/templates/admin/utilisateurs.php new file mode 100644 index 0000000..b4f31e1 --- /dev/null +++ b/templates/admin/utilisateurs.php @@ -0,0 +1,64 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
IdentifiantE-mailRôleStatutCréé leActions
+ + + vous + + + + Actif + + Inactif + + +
+ +
/basculer"> + + +
+
/supprimer" onsubmit="return confirm('Supprimer définitivement ce compte ?');"> + + +
+ + — + +
+
+
diff --git a/templates/auth/connexion.php b/templates/auth/connexion.php new file mode 100644 index 0000000..4dbce52 --- /dev/null +++ b/templates/auth/connexion.php @@ -0,0 +1,39 @@ +

Connexion

+

Accédez à votre bibliothèque personnelle.

+ +
+ + +
+ + " + > +
+ +
+ + +
+ + +
+ +

+ Pas encore de compte ? + Créer un compte +

diff --git a/templates/auth/inscription.php b/templates/auth/inscription.php new file mode 100644 index 0000000..5a96473 --- /dev/null +++ b/templates/auth/inscription.php @@ -0,0 +1,53 @@ +

Inscription

+

Créez votre compte pour gérer vos consoles et jeux.

+ +
+ + +
+ + " + > +
+ +
+ + " + > +
+ +
+ + +
Minimum 6 caractères.
+
+ + +
+ +

+ Déjà inscrit ? + Se connecter +

diff --git a/templates/consoles/detail.php b/templates/consoles/detail.php new file mode 100644 index 0000000..e0f7310 --- /dev/null +++ b/templates/consoles/detail.php @@ -0,0 +1,49 @@ + + +
+
+

Jeux sur cette console

+ jeu 1 ? "x" : ""; ?> +
+ + +
+ +

Aucun jeu associé à cette console.

+
+ + + +
diff --git a/templates/consoles/formulaire.php b/templates/consoles/formulaire.php new file mode 100644 index 0000000..06d2f7c --- /dev/null +++ b/templates/consoles/formulaire.php @@ -0,0 +1,48 @@ + Helpers::champ("nom"), + "constructeur" => Helpers::champ("constructeur"), + "annee" => Helpers::champ("annee"), + "notes" => Helpers::champ("notes") +]; +?> + + + +
+ + +
+
+ + "> +
+
+ + "> +
+
+ + " value=""> +
+
+ + +
+
+ +
+ +
+
diff --git a/templates/consoles/liste.php b/templates/consoles/liste.php new file mode 100644 index 0000000..73792c9 --- /dev/null +++ b/templates/consoles/liste.php @@ -0,0 +1,48 @@ + + + +
+ +

Aucune console pour l'instant. Ajoutez votre première machine.

+ Ajouter une console +
+ +
+ + + +
+ diff --git a/templates/erreurs/404.php b/templates/erreurs/404.php new file mode 100644 index 0000000..dfb02f2 --- /dev/null +++ b/templates/erreurs/404.php @@ -0,0 +1,5 @@ +
+

404

+

Cette page n'existe pas dans votre inventaire.

+ Retour à l'accueil +
diff --git a/templates/jeux/detail.php b/templates/jeux/detail.php new file mode 100644 index 0000000..d23ab3d --- /dev/null +++ b/templates/jeux/detail.php @@ -0,0 +1,73 @@ +
+
+ " + alt="Jaquette de " + > +
+
+

+

+
+ "> + + + + + + + + +
+
+ /modifier" class="btn btn-neon"> + Modifier + +
/supprimer" onsubmit="return confirm('Supprimer ce jeu ?');"> + + +
+
+
+
+ +
+
+

Description

+ +

+ +

Aucune description.

+ +
+ + +
diff --git a/templates/jeux/formulaire.php b/templates/jeux/formulaire.php new file mode 100644 index 0000000..68cf27f --- /dev/null +++ b/templates/jeux/formulaire.php @@ -0,0 +1,126 @@ + + + + + +
+

Enrichissement automatique (RAWG)

+

Recherchez un titre pour préremplir jaquette, description et métadonnées.

+
+
+ + "> +
+
+ +
+
+ +
+ +
+ Enrichissement API désactivé : aucune clé RAWG_API_KEY configurée. La saisie manuelle reste disponible. +
+ + +
+ + "> + "> + +
+
+
+ " + alt="Aperçu de la jaquette" + > +
+
+
+
+
+ + "> +
+
+ + +
+
+ + +
+
+ + "> +
+
+ + "> +
+
+ + "> +
+
+ + +
Séparez les genres par des virgules.
+
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+ + + + diff --git a/templates/jeux/liste.php b/templates/jeux/liste.php new file mode 100644 index 0000000..7e303ec --- /dev/null +++ b/templates/jeux/liste.php @@ -0,0 +1,73 @@ + + +
+
+
+ + "> +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+ +

Aucun jeu ne correspond à vos critères.

+
+ +
+ + + "> + " + alt="Jaquette de " + loading="lazy" + > +
+

+

+ "> + + +
+
+ +
+ diff --git a/templates/layout.php b/templates/layout.php new file mode 100644 index 0000000..e391737 --- /dev/null +++ b/templates/layout.php @@ -0,0 +1,75 @@ + + + + + + <?php print Helpers::echapper(($titrePage ?? "JeuxVideoTech") . " — " . Config::get("appName")); ?> + + + + + + + + + + + + +
+ + +
+ + + + + + + diff --git a/templates/layout_auth.php b/templates/layout_auth.php new file mode 100644 index 0000000..ab70c67 --- /dev/null +++ b/templates/layout_auth.php @@ -0,0 +1,29 @@ + + + + + + <?php print Helpers::echapper(($titrePage ?? "JeuxVideoTech") . " — " . Config::get("appName")); ?> + + + + + + + + + +
+
+ ▶ +

+

Votre inventaire de consoles et de jeux, centralisé.

+
+
+ + +
+
+ + + diff --git a/templates/partials/flash.php b/templates/partials/flash.php new file mode 100644 index 0000000..63208f3 --- /dev/null +++ b/templates/partials/flash.php @@ -0,0 +1,7 @@ + + +
alert-dismissible fade show app-alert" role="alert"> + + +
+