This commit is contained in:
2026-06-18 07:38:41 +02:00
parent ec815787ed
commit 2d4d139f9d
9 changed files with 1696 additions and 530 deletions
+197 -204
View File
@@ -3,239 +3,232 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Administration — Mon Cinéma</title>
<link rel="stylesheet" href="../css/public.css">
<link rel="stylesheet" href="../css/admin.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
<title>Dashboard</title>
<style>
/* Styles pour le système d'onglets */
.admin-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border, #333);
padding-bottom: 0.5rem;
}
.tab-btn {
background: transparent;
border: none;
color: var(--muted, #888);
padding: 0.6rem 1.2rem;
font-family: 'DM Sans', sans-serif;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tab-btn:hover {
color: var(--text, #fff);
background: rgba(255,255,255,0.03);
}
.tab-btn.active {
color: #000;
background: var(--gold, #C9A84C);
}
/* Style pour le badge de format dans le tableau */
.badge-format {
background: rgba(255,255,255,0.1);
color: var(--text, #fff);
padding: 0.2rem 0.5rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.05em;
}
</style>
</head>
<body>
<div id="page-admin" class="page active">
<div class="admin-wrap">
<div class="admin-header">
<h1 class="admin-title">Back<span>office</span></h1>
<div class="admin-actions" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<button class="btn btn-gold" onclick="openAddModal()"><i class="ti ti-plus" aria-hidden="true"></i> Ajouter un film</button>
<!-- GROUPE CRITIQUES (Affiché par défaut) -->
<button id="actions-critiques-export" class="btn btn-outline" onclick="exportToCSV()" title="Exporter mes critiques en CSV"><i class="ti ti-download" aria-hidden="true"></i> Exporter mes Critiques</button>
<button id="actions-critiques-import" class="btn btn-outline" onclick="document.getElementById('csv-import-file').click()" title="Importer un fichier CSV de critiques"><i class="ti ti-upload" aria-hidden="true"></i> Importer mes Critiques</button>
<input type="file" id="csv-import-file" accept=".csv" style="display: none;" onchange="importFromCSV(this)">
<div class="admin-wrap">
<!-- GROUPE VIDÉOTHÈQUE (Masqué par défaut au démarrage) -->
<button id="actions-video-export" class="btn btn-outline" style="display: none;" onclick="exportVideothequeToCSV()" title="Exporter ma vidéothèque en CSV"><i class="ti ti-download" aria-hidden="true"></i> Exporter ma Vidéothèque</button>
<button id="actions-video-import" class="btn btn-outline" style="display: none;" onclick="document.getElementById('csv-video-file').click()" title="Importer l'inventaire de ma vidéothèque (.csv)"><i class="ti ti-upload" aria-hidden="true"></i> Importer ma Vidéothèque</button>
<input type="file" id="csv-video-file" accept=".csv" style="display: none;" onchange="importVideothequeFromInput(this)">
<!-- BANNIÈRE SÉCURITÉ (premier lancement) -->
<div class="security-banner" id="security-banner" style="display:none;">
<span><i class="ti ti-alert-triangle"></i> <strong>Attention :</strong> Aucun mot de passe n'est défini. Votre espace admin est accessible à tous !</span>
<button onclick="openPasswordModal()">Définir un mot de passe</button>
</div>
<!-- BOUTON SUPPRESSION EN MASSE -->
<button id="btn-bulk-delete" class="btn" style="background: var(--red, #e54e4e); color: #fff; border: none; display: none;" onclick="deleteSelectedFilms()">
<i class="ti ti-trash-x" aria-hidden="true"></i> Supprimer la sélection (<span id="bulk-select-count">0</span>)
</button>
<button class="btn btn-outline" onclick="goPublic()"><i class="ti ti-eye" aria-hidden="true"></i> Voir le site</button>
</div>
<!-- EN-TÊTE ADMIN -->
<div class="admin-header">
<div>
<h1 class="admin-title">Espace <span>Admin</span></h1>
<p id="admin-subtitle" style="color:var(--muted); font-size:0.82rem; margin-top:0.3rem;">Gestion de vos critiques de films</p>
</div>
<div class="admin-tabs">
<button class="tab-btn active" id="tab-critiques" onclick="switchTab('critique')">
<i class="ti ti-blockquote"></i> Mes Critiques
</button>
<button class="tab-btn" id="tab-videotheque" onclick="switchTab('videotheque')">
<i class="ti ti-disc"></i> Ma Vidéothèque
</button>
</div>
<div class="api-notice" id="api-notice">
<strong>Clé API TMDB requise pour la recherche d'affiches.</strong><br>
Gratuit sur <a href="https://www.themoviedb.org/settings/api" target="_blank">themoviedb.org/settings/api</a> (inscription gratuite, clé disponible immédiatement).<br>
<div style="display:flex;gap:0.6rem;margin-top:0.7rem;align-items:center;">
<input id="tmdb-key-input" type="text" placeholder="Colle ta clé TMDB ici" style="flex:1;background:var(--surface);border:1px solid var(--border);color:var(--text);font-family:'DM Sans',sans-serif;font-size:0.82rem;padding:0.5rem 0.7rem;border-radius:3px;outline:none;" />
<button class="btn btn-gold" style="white-space:nowrap;" onclick="saveTmdbKey()">Enregistrer</button>
</div>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th style="width:40px; text-align:center;">
<input type="checkbox" id="th-select-all" onclick="toggleSelectAll(this)" style="cursor:pointer; transform: scale(1.2);">
</th>
<th style="width:50px">Affiche</th>
<th>Titre</th>
<th>Année</th>
<th>Réalisateur</th>
<th id="th-dynamic-col">Note</th>
<th style="width:170px">Actions</th>
</tr>
</thead>
<tbody id="admin-tbody"></tbody>
</table>
</div>
<div class="empty-state" id="admin-empty" style="display:none">
<i class="ti ti-clapperboard-open" aria-hidden="true"></i>
<p id="empty-message">Aucun film pour l'instant. Ajoutez votre première critique !</p>
<div class="admin-actions">
<button class="btn btn-outline" onclick="openConfigModal()"><i class="ti ti-key"></i> Clé TMDB</button>
<button class="btn btn-outline" onclick="openPasswordModal()"><i class="ti ti-lock"></i> Mot de passe</button>
<button class="btn btn-danger" onclick="logout()"><i class="ti ti-logout"></i> Quitter</button>
</div>
</div>
</div>
<div class="overlay" id="form-overlay" role="dialog" aria-modal="true">
<div class="modal">
<button class="modal-close" onclick="closeModal()" aria-label="Fermer"><i class="ti ti-x"></i></button>
<h2 class="modal-h" id="modal-title">Nouvelle entrée</h2>
<!-- ONGLETS -->
<div class="admin-tabs">
<button class="tab-btn active" id="btn-tab-critique" onclick="switchAdminTab('critique')">
<i class="ti ti-message-star"></i> Critiques
</button>
<button class="tab-btn" id="btn-tab-videotheque" onclick="switchAdminTab('videotheque')">
<i class="ti ti-device-tv"></i> Vidéothèque
</button>
</div>
<p style="font-size:0.75rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--muted);margin-bottom:0.4rem;">Recherche automatique d'affiche</p>
<div class="search-row">
<input id="search-input" type="text" placeholder="Nom du film à rechercher…" onkeydown="if(event.key==='Enter'){event.preventDefault();searchTMDB();}" />
<button class="btn-search" onclick="searchTMDB()" aria-label="Rechercher"><i class="ti ti-search"></i></button>
</div>
<div id="search-results-wrap"></div>
<!-- IMPORT CSV (Letterboxd) -->
<div id="import-section" style="background:var(--surface2); border:2px dashed var(--border); border-radius:8px; padding:1.8rem; text-align:center; margin-bottom:1.8rem;">
<i class="ti ti-file-upload" style="font-size:2.2rem; color:var(--gold); display:block; margin-bottom:0.6rem;"></i>
<p style="margin-bottom:1rem; font-size:0.9rem; color:var(--muted);">Importez vos exports <strong style="color:var(--text);">Letterboxd</strong> (.csv)</p>
<input type="file" id="csv-file-input" accept=".csv" style="display:none;" onchange="handleCsvUpload(this)">
<button class="btn btn-outline" onclick="document.getElementById('csv-file-input').click()">
<i class="ti ti-file-spreadsheet"></i> Choisir un fichier .csv
</button>
</div>
<hr class="divider" />
<!-- BARRE D'ACTIONS EN MASSE -->
<div class="bulk-actions-bar" id="bulk-actions-bar" style="display:none;">
<span style="font-size:0.88rem; color:var(--gold); font-weight:500;">
<i class="ti ti-checkbox"></i> <span id="bulk-count">0</span> élément(s) sélectionné(s)
</span>
<button class="btn btn-danger" onclick="executeBulkDelete()">
<i class="ti ti-trash"></i> Supprimer la sélection
</button>
</div>
<div class="form-group">
<label for="f-type">Type d'ajout</label>
<select id="f-type" onchange="handleTypeChange(this.value)" style="width:100%; background:var(--surface); border:1px solid var(--border); color:var(--text); padding:0.6rem; border-radius:3px; font-family:inherit;">
<option value="critique">Écrire une critique (Journal)</option>
<option value="videotheque">Ajouter à ma vidéothèque physique</option>
</select>
</div>
<!-- TOOLBAR -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<span class="count" id="admin-count-label">Chargement…</span>
<button class="btn btn-gold" onclick="openAddModal()"><i class="ti ti-plus"></i> Ajouter</button>
</div>
<!-- TABLEAU -->
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th style="width:42px; text-align:center;">
<input type="checkbox" id="select-all-checkbox" onclick="toggleSelectAll(this)">
</th>
<th style="width:56px; text-align:center;">Affiche</th>
<th>Titre</th>
<th>Année</th>
<th>Réalisateur</th>
<th id="th-dynamic">Note</th>
<th style="width:110px; text-align:center;">Actions</th>
</tr>
</thead>
<tbody id="admin-table-body"></tbody>
</table>
</div>
</div><!-- /.admin-wrap -->
<!-- ══════════════════════════════════════════
MODALE : AJOUTER / MODIFIER UN FILM
══════════════════════════════════════════ -->
<div class="overlay" id="admin-modal" onclick="if(event.target===this) closeAdminModal()">
<div class="modal" style="max-width:600px;">
<button class="modal-close" onclick="closeAdminModal()"><i class="ti ti-x"></i></button>
<h3 class="modal-h" id="modal-form-title">Ajouter une œuvre</h3>
<form id="film-form" onsubmit="saveFilmForm(event)">
<input type="hidden" id="f-id">
<div class="form-row">
<div class="form-group">
<label for="f-title">Titre</label>
<input id="f-title" type="text" placeholder="ex : Mulholland Drive" />
<label>Titre du film *</label>
<input type="text" id="f-title" required autocomplete="off">
</div>
<div class="form-group">
<label for="f-year">Année</label>
<input id="f-year" type="text" placeholder="ex : 2001" maxlength="4" />
</div>
</div>
<div class="form-group">
<label for="f-director">Réalisateur</label>
<input id="f-director" type="text" placeholder="ex : David Lynch" />
</div>
<div id="form-group-videotheque-fields" style="display: none;">
<div class="form-row" style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
<div class="form-group" style="flex: 1;">
<label for="f-format">Format physique</label>
<select id="f-format" style="width:100%; background:var(--surface); border:1px solid var(--border); color:var(--text); padding:0.6rem; border-radius:3px; font-family:inherit;">
<option value="dvd">DVD</option>
<option value="bluray">Blu-ray</option>
<option value="bluray_4k">Blu-ray 4K Ultra HD</option>
<option value="vhs">VHS</option>
<option value="collector">Édition Collector / Coffret</option>
<div class="form-row">
<div class="form-group">
<label>Année</label>
<input type="number" id="f-year" min="1800" max="2100">
</div>
<div class="form-group">
<label>Réalisateur</label>
<input type="text" id="f-director">
</div>
</div>
<div class="form-group">
<label>URL de l'affiche</label>
<input type="url" id="f-poster" placeholder="https://image.tmdb.org/…">
</div>
<!-- Champs spécifiques CRITIQUE -->
<div id="form-critique-fields">
<div class="form-group">
<label>Note (1 à 5 étoiles)</label>
<select id="f-rating">
<option value="5">★★★★★ (5/5)</option>
<option value="4">★★★★☆ (4/5)</option>
<option value="3" selected>★★★☆☆ (3/5)</option>
<option value="2">★★☆☆☆ (2/5)</option>
<option value="1">★☆☆☆☆ (1/5)</option>
</select>
</div>
<div class="form-group" style="flex: 1;">
<label for="f-length">Durée (minutes)</label>
<input id="f-length" type="number" placeholder="ex : 107" />
<div class="form-group">
<label>Critique</label>
<textarea id="f-review" rows="5"></textarea>
</div>
<div class="form-group">
<label>Plateforme / Mode de visionnage</label>
<input type="text" id="f-streaming" placeholder="Ex : Canal+, Cinéma, Blu-ray…">
</div>
</div>
<div class="form-row" style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
<div class="form-group" style="flex: 1;">
<label for="f-publisher">Éditeur / Studio</label>
<input id="f-publisher" type="text" placeholder="ex : Universal Pictures" />
<!-- Champs spécifiques VIDÉOTHÈQUE -->
<div id="form-videotheque-fields" style="display:none;">
<div class="form-row">
<div class="form-group">
<label>Format physique</label>
<input type="text" id="f-format" placeholder="4K Ultra HD, Blu-ray, DVD…">
</div>
<div class="form-group">
<label>Durée (minutes)</label>
<input type="number" id="f-length">
</div>
</div>
<div class="form-group" style="flex: 1;">
<label for="f-ean">Code-barres (EAN13)</label>
<input id="f-ean" type="text" placeholder="ex : 7321950374984" />
<div class="form-row">
<div class="form-group">
<label>Éditeur</label>
<input type="text" id="f-publisher">
</div>
<div class="form-group">
<label>Format image</label>
<input type="text" id="f-aspect" placeholder="2.39:1, 1.85:1…">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Code barre (EAN)</label>
<input type="text" id="f-ean">
</div>
<div class="form-group">
<label>Nombre de disques</label>
<input type="number" id="f-discs" value="1" min="1">
</div>
</div>
<div class="form-group">
<label>Synopsis / Notes d'édition</label>
<textarea id="f-description" rows="4"></textarea>
</div>
</div>
<div class="form-row" style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
<div class="form-group" style="flex: 1;">
<label for="f-discs">Nombre de disques</label>
<input id="f-discs" type="number" placeholder="ex : 2" defaultValue="1" />
</div>
<div class="form-group" style="flex: 1;">
<label for="f-aspect">Format d'image (Aspect Ratio)</label>
<input id="f-aspect" type="text" placeholder="ex : 2.35:1 ou 1.66:1" />
</div>
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label for="f-description">Description / Synopsis complet</label>
<textarea id="f-description" placeholder="Description extraite du catalogue..." style="height: 100px;"></textarea>
</div>
</div>
<div class="poster-preview" id="poster-preview-wrap">
<div class="poster-preview-none" id="poster-preview-none"><i class="ti ti-photo-off"></i></div>
<img id="poster-preview-img" src="" alt="" style="display:none" />
<div class="poster-preview-info" id="poster-preview-info">Aucune affiche sélectionnée.<br>Faites une recherche ou collez une URL.</div>
</div>
<div class="form-group">
<label for="f-poster">URL de l'affiche (optionnel)</label>
<input id="f-poster" type="url" placeholder="https://…" oninput="updatePosterPreview(this.value)" />
</div>
<div id="form-group-critique-fields">
<div class="form-group">
<label>Note</label>
<div class="star-select" id="star-select">
<button type="button" onclick="setRating(1)" aria-label="1 étoile"></button>
<button type="button" onclick="setRating(2)" aria-label="2 étoiles"></button>
<button type="button" onclick="setRating(3)" aria-label="3 étoiles"></button>
<button type="button" onclick="setRating(4)" aria-label="4 étoiles"></button>
<button type="button" onclick="setRating(5)" aria-label="5 étoiles"></button>
</div>
</div>
<div class="form-group">
<label for="f-review">Ma critique</label>
<textarea id="f-review" placeholder="Écris ta critique ici…"></textarea>
</div>
</div>
<button type="button" class="btn-save" onclick="saveFilm()">Enregistrer</button>
<button type="submit" class="btn-save"><i class="ti ti-device-floppy"></i> Enregistrer</button>
</form>
</div>
</div>
<script src="../js/admin.js?v=4"></script>
<!-- ══════════════════════════════════════════
MODALE : CLÉ API TMDB
══════════════════════════════════════════ -->
<div class="overlay" id="config-modal" onclick="if(event.target===this) closeConfigModal()">
<div class="modal" style="max-width:440px;">
<button class="modal-close" onclick="closeConfigModal()"><i class="ti ti-x"></i></button>
<h3 class="modal-h"><i class="ti ti-key"></i> Clé API TMDB</h3>
<div class="api-notice">
Obtenez votre clé gratuite sur <a href="https://www.themoviedb.org/settings/api" target="_blank">themoviedb.org</a>.
Elle sera chiffrée en base de données.
</div>
<div class="form-group">
<label>Clé API (v3)</label>
<input type="password" id="tmdb-key-input" placeholder="Saisir la clé…">
</div>
<button class="btn-save" onclick="saveTmdbKey()">Sauvegarder</button>
</div>
</div>
<!-- ══════════════════════════════════════════
MODALE : MOT DE PASSE
══════════════════════════════════════════ -->
<div class="overlay" id="password-modal" onclick="if(event.target===this) closePasswordModal()">
<div class="modal" style="max-width:400px;">
<button class="modal-close" onclick="closePasswordModal()"><i class="ti ti-x"></i></button>
<h3 class="modal-h"><i class="ti ti-lock"></i> Changer le mot de passe</h3>
<div class="form-group">
<label>Nouveau mot de passe</label>
<input type="password" id="new-password-input" placeholder="Minimum 4 caractères">
</div>
<div class="form-group">
<label>Confirmation</label>
<input type="password" id="new-password-confirm" placeholder="Répétez le mot de passe">
</div>
<p id="pwd-error" style="color:#c0392b; font-size:0.82rem; display:none; margin-bottom:0.8rem;"></p>
<button class="btn-save" onclick="saveNewPassword()">Mettre à jour</button>
</div>
</div>
<script src="../js/admin.js"></script>
</body>
</html>
+108 -9
View File
@@ -1,16 +1,115 @@
<!DOCTYPE html>
<html lang="fr"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="../css/admin.css">
<title>Login</title></head>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="../css/admin.css">
<title>Login - Backoffice</title>
</head>
<body>
<div id="page-login" class="page active">
<div class="login-wrap">
<h2>Backoffice</h2>
<input type="password" id="login-pwd" placeholder="Mot de passe" />
<p class="login-err" id="login-err">Mot de passe incorrect.</p>
<button class="btn btn-gold" onclick="doLogin()">Accéder</button>
<div id="login-form-block">
<input type="password" id="login-pwd" placeholder="Mot de passe" onkeydown="if(event.key==='Enter') doLogin()" />
<p class="login-err" id="login-err">Mot de passe incorrect.</p>
<button class="btn btn-gold" onclick="doLogin()">Accéder</button>
</div>
<div id="blank-setup-block" style="display:none; text-align:center;">
<p style="color:var(--muted, #aaa); font-size:0.85rem; margin-bottom:1.5rem; line-height:1.6;">
Aucun mot de passe configuré sur le serveur.<br>Connectez-vous directement pour en définir un.
</p>
<button class="btn btn-gold" onclick="doLoginBlank()" style="width:100%;">Se connecter sans mot de passe</button>
</div>
</div>
</div>
<script src="../js/admin.js"></script>
</body></html>
<script>
const API_URL = '../api.php';
document.addEventListener('DOMContentLoaded', async () => {
// Si déjà connecté, rediriger vers le dashboard
if (localStorage.getItem('token')) {
window.location.href = 'dashboard.html';
return;
}
const formBlock = document.getElementById('login-form-block');
const blankBlock = document.getElementById('blank-setup-block');
try {
const res = await fetch(`${API_URL}?action=check_security_status`);
const data = await res.json();
if (data.is_blank) {
formBlock.style.display = 'none';
blankBlock.style.display = 'block';
}
} catch(e) {
console.warn("API indisponible — mode secours activé.");
formBlock.style.display = 'none';
blankBlock.style.display = 'block';
}
});
async function doLoginBlank() {
try {
const res = await fetch(`${API_URL}?action=login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: '' })
});
const data = await res.json();
if (data.success) {
localStorage.setItem('token', data.token);
window.location.href = 'dashboard.html';
}
} catch(e) {
console.error(e);
}
}
async function doLogin() {
const pwd = document.getElementById('login-pwd').value;
const errEl = document.getElementById('login-err');
errEl.style.display = 'none';
try {
const res = await fetch(`${API_URL}?action=login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pwd })
});
const data = await res.json();
if (data.success) {
localStorage.setItem('token', data.token);
// Première connexion : inviter à définir un mot de passe
if (data.blank) {
const newPwd = prompt("Première connexion ! Définissez votre mot de passe administrateur :");
if (newPwd && newPwd.trim().length >= 4) {
await fetch(`${API_URL}?action=setup_admin`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': data.token
},
body: JSON.stringify({ password: newPwd.trim() })
});
}
}
window.location.href = 'dashboard.html';
} else {
errEl.style.display = 'block';
}
} catch(e) {
console.error(e);
errEl.style.display = 'block';
}
}
</script>
</body>
</html>
+470
View File
@@ -0,0 +1,470 @@
<?php
header("Content-Type: application/json; charset=UTF-8");
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
define('ENCRYPTION_KEY', 'MaCleSecreteSuperRobuste123!');
$host = 'localhost';
$db = 'mon_cinema';
$user = 'root';
$pass = '';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
try {
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci"
]);
} catch (\PDOException $e) {
echo json_encode(["error" => "Erreur BDD : " . $e->getMessage()]);
exit;
}
// ── Chiffrement ──────────────────────────────────────────────────
function encryptData($data) {
$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
$encrypted = openssl_encrypt($data, 'aes-256-cbc', ENCRYPTION_KEY, 0, $iv);
return base64_encode($encrypted . '::' . $iv);
}
function decryptData($data) {
if (!$data) return '';
list($encrypted_data, $iv) = explode('::', base64_decode($data), 2);
return openssl_decrypt($encrypted_data, 'aes-256-cbc', ENCRYPTION_KEY, 0, $iv);
}
// ── Génération d'ID stable et compatible bigint (PHP 32 et 64 bits) ──
// Produit un entier sur 9 chiffres (< 2^31) → safe partout
// crc32() PHP retourne un int signé 32 bits → on force positif avec & 0x7FFFFFFF
// puis on module pour rester dans une plage confortable sans collision sur ~50k films
function makeStableId($title, $year) {
$key = strtolower(trim($title)) . '|' . trim($year);
$hash = abs(crc32($key)); // abs() = toujours positif
// Reste dans [100000000, 2099999999] → 9-10 chiffres, bien < BIGINT MAX
return ($hash % 2000000000) + 100000000;
}
// Pour les ajouts manuels : timestamp Unix en secondes (10 chiffres, safe)
function makeNewId() {
// On ajoute un suffixe aléatoire sur 3 chiffres pour éviter les doublons
// si deux films sont ajoutés dans la même seconde
return intval(time() . rand(100, 999));
// time() = 10 chiffres, rand = 3 → 13 chiffres total
// Mais BIGINT MAX = 9223372036854775807 (19 chiffres) → OK même en 64-bit
// Et en PHP 32-bit, time() seul (10 chiffres) dépasse INT_MAX...
// Solution : utiliser directement crc32 d'un uuid
}
function makeNewIdSafe() {
// Génère un ID unique sur 9-10 chiffres safe en 32 ET 64 bits
$unique = uniqid('', true) . rand(1000, 9999);
return (abs(crc32($unique)) % 2000000000) + 100000000;
}
// ── Conversion note Letterboxd (/5 avec demi-points) → entier 1-5 ──
function convertRating($rawRating) {
$raw = floatval($rawRating);
if ($raw <= 0) return 3; // pas de note → neutre
// Arrondi au plus proche, clampé entre 1 et 5
$rounded = (int) round($raw);
return max(1, min(5, $rounded));
}
// ── Authentification ─────────────────────────────────────────────
function checkAuth($pdo) {
$stmtCheck = $pdo->query("SELECT COUNT(*) as total FROM users");
if ($stmtCheck->fetch()['total'] == 0) return true;
$token = '';
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
$token = $_SERVER['HTTP_AUTHORIZATION'];
} elseif (function_exists('apache_request_headers')) {
$headers = apache_request_headers();
if (isset($headers['Authorization'])) $token = $headers['Authorization'];
}
$expectedToken = md5(ENCRYPTION_KEY . 'session');
if ($token !== $expectedToken) {
http_response_code(403);
echo json_encode(["error" => "Accès interdit. Session expirée."]);
exit;
}
}
// ── Requête TMDB ─────────────────────────────────────────────────
function fetchTmdb($url) {
if (function_exists('curl_init')) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_USERAGENT, 'MonCinemaApp/1.0');
$response = curl_exec($ch);
curl_close($ch);
return $response;
}
$opts = [
"http" => ["method" => "GET", "header" => "User-Agent: MonCinemaApp/1.0\r\n", "timeout" => 8],
"ssl" => ["verify_peer" => false, "verify_peer_name" => false]
];
return @file_get_contents($url, false, stream_context_create($opts));
}
// ════════════════════════════════════════════════════════════════
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
switch ($method) {
// ════════════════════════════════════════════════════════════
case 'GET':
if ($action === 'get_films') {
$stmtC = $pdo->query("SELECT *, 'critique' AS type FROM critiques ORDER BY created_at DESC");
$critiques = $stmtC->fetchAll();
$stmtV = $pdo->query("SELECT *, 'videotheque' AS type FROM videotheque ORDER BY created_at DESC");
$videotheque = $stmtV->fetchAll();
echo json_encode(array_merge($critiques, $videotheque), JSON_UNESCAPED_UNICODE);
}
elseif ($action === 'get_tmdb_key_status') {
checkAuth($pdo);
$stmt = $pdo->prepare(
"SELECT COUNT(*) as total FROM config
WHERE key_name = 'tmdb_api_key'
AND key_value IS NOT NULL AND key_value != ''"
);
$stmt->execute();
echo json_encode(["exists" => ($stmt->fetch()['total'] > 0)]);
}
elseif ($action === 'search_tmdb') {
checkAuth($pdo);
$query = $_GET['query'] ?? '';
$stmt = $pdo->prepare("SELECT key_value FROM config WHERE key_name = 'tmdb_api_key'");
$stmt->execute();
$res = $stmt->fetch();
$key = $res ? decryptData($res['key_value']) : '';
if (!$key) { echo json_encode(["error" => "Clé TMDB non configurée."]); exit; }
$url = "https://api.themoviedb.org/3/search/movie?api_key={$key}&query=" . urlencode($query) . "&language=fr-FR";
$response = fetchTmdb($url);
echo $response ?: json_encode(["results" => []]);
}
elseif ($action === 'check_security_status') {
$stmt = $pdo->query("SELECT COUNT(*) as total FROM users");
echo json_encode(["is_blank" => ($stmt->fetch()['total'] == 0)]);
}
break;
// ════════════════════════════════════════════════════════════
case 'POST':
// ── Import CSV Letterboxd ─────────────────────────────
if ($action === 'import_csv') {
checkAuth($pdo);
if (!isset($_FILES['csv_file']) || $_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(["error" => "Fichier CSV invalide ou manquant."]);
exit;
}
// Récupérer la clé TMDB (optionnelle)
$stmtK = $pdo->prepare("SELECT key_value FROM config WHERE key_name = 'tmdb_api_key'");
$stmtK->execute();
$resK = $stmtK->fetch();
$tmdbKey = $resK ? decryptData($resK['key_value']) : '';
$file = $_FILES['csv_file']['tmp_name'];
$handle = fopen($file, 'r');
if (!$handle) {
http_response_code(500);
echo json_encode(["error" => "Impossible d'ouvrir le fichier."]);
exit;
}
// Gestion BOM UTF-8 (présent ou non)
$bom = fread($handle, 3);
if ($bom !== "\xEF\xBB\xBF") rewind($handle);
// Lire l'en-tête
$header = fgetcsv($handle, 0, ',', '"');
if (!$header) {
fclose($handle);
echo json_encode(["error" => "Fichier CSV vide."]);
exit;
}
// Nettoyer les noms de colonnes (espaces, BOM résiduels)
$header = array_map(function($col) {
return trim(preg_replace('/[\x00-\x1F\x7F\xEF\xBB\xBF]/u', '', $col));
}, $header);
$colName = array_search('Name', $header);
$colYear = array_search('Year', $header);
$colRating = array_search('Rating', $header);
$colReview = array_search('Review', $header); // absent dans ratings.csv → false
if ($colName === false || $colYear === false || $colRating === false) {
fclose($handle);
echo json_encode([
"error" => "Format non reconnu. Colonnes attendues : Name, Year, Rating. Colonnes trouvées : " . implode(', ', $header)
]);
exit;
}
$importedCount = 0;
$errors = [];
while (($row = fgetcsv($handle, 0, ',', '"')) !== false) {
// Ignorer les lignes trop courtes
if (count($row) <= max($colName, $colYear, $colRating)) continue;
$title = trim($row[$colName]);
$year = trim($row[$colYear]);
$rating = convertRating($row[$colRating]);
$review = ($colReview !== false && isset($row[$colReview])) ? trim($row[$colReview]) : '';
if (empty($title)) continue;
// ID stable basé sur titre+année, safe en 32 ET 64 bits
$id = makeStableId($title, $year);
// Récupérer données existantes pour ne pas écraser
$stmtCheck = $pdo->prepare("SELECT review, poster, director FROM critiques WHERE id = :id");
$stmtCheck->execute([':id' => $id]);
$existing = $stmtCheck->fetch();
$poster = $existing['poster'] ?? '';
$director = $existing['director'] ?? '';
// Conserver la critique existante si le CSV n'en fournit pas
if (empty($review) && !empty($existing['review'])) {
$review = $existing['review'];
}
// Enrichissement TMDB si données manquantes et clé disponible
if ((empty($poster) || empty($director)) && !empty($tmdbKey)) {
$apiUrl = "https://api.themoviedb.org/3/search/movie"
. "?api_key={$tmdbKey}"
. "&query=" . urlencode($title)
. ($year ? "&year={$year}" : '')
. "&language=fr-FR";
$tmdbRes = fetchTmdb($apiUrl);
if ($tmdbRes) {
$tmdbData = json_decode($tmdbRes, true);
if (!empty($tmdbData['results'][0])) {
$movieData = $tmdbData['results'][0];
if (empty($poster) && !empty($movieData['poster_path'])) {
$poster = "https://image.tmdb.org/t/p/w300" . $movieData['poster_path'];
}
if (empty($director) && !empty($movieData['id'])) {
$creditsRes = fetchTmdb("https://api.themoviedb.org/3/movie/{$movieData['id']}/credits?api_key={$tmdbKey}");
if ($creditsRes) {
$creditsData = json_decode($creditsRes, true);
foreach ($creditsData['crew'] ?? [] as $member) {
if ($member['job'] === 'Director') {
$director = $member['name'];
break;
}
}
}
}
}
}
}
try {
$stmt = $pdo->prepare(
"REPLACE INTO critiques (id, title, year, director, rating, review, poster, streaming)
VALUES (:id, :title, :year, :director, :rating, :review, :poster, '')"
);
$stmt->execute([
':id' => $id,
':title' => $title,
':year' => $year,
':director' => $director,
':rating' => $rating,
':review' => $review,
':poster' => $poster,
]);
$importedCount++;
} catch (\PDOException $e) {
$errors[] = "'{$title}' : " . $e->getMessage();
}
}
fclose($handle);
$result = ["success" => true, "imported" => $importedCount];
if (!empty($errors)) $result['warnings'] = array_slice($errors, 0, 10);
echo json_encode($result, JSON_UNESCAPED_UNICODE);
exit;
}
// ── Lire le body JSON ─────────────────────────────────
$data = json_decode(file_get_contents('php://input'), true) ?? [];
// ── Connexion ─────────────────────────────────────────
if ($action === 'login') {
$stmtCheck = $pdo->query("SELECT COUNT(*) as total FROM users");
if ($stmtCheck->fetch()['total'] == 0) {
echo json_encode([
"success" => true,
"token" => md5(ENCRYPTION_KEY . 'session'),
"blank" => true
]);
exit;
}
$stmt = $pdo->prepare("SELECT password_hash FROM users WHERE username = 'admin'");
$stmt->execute();
$userRow = $stmt->fetch();
if ($userRow && password_verify($data['password'] ?? '', $userRow['password_hash'])) {
echo json_encode([
"success" => true,
"token" => md5(ENCRYPTION_KEY . 'session'),
"blank" => false
]);
} else {
http_response_code(401);
echo json_encode(["error" => "Mot de passe incorrect."]);
}
}
// ── Sauvegarde film ───────────────────────────────────
elseif ($action === 'save_film') {
checkAuth($pdo);
$type = $data['type'] ?? 'critique';
$id = (!empty($data['id'])) ? intval($data['id']) : makeNewIdSafe();
if ($type === 'critique') {
$stmt = $pdo->prepare(
"REPLACE INTO critiques (id, title, year, director, rating, review, poster, streaming)
VALUES (:id, :title, :year, :director, :rating, :review, :poster, :streaming)"
);
$stmt->execute([
':id' => $id,
':title' => $data['title'] ?? '',
':year' => $data['year'] ?? '',
':director' => $data['director'] ?? '',
':rating' => max(1, min(5, intval($data['rating'] ?? 3))),
':review' => $data['review'] ?? '',
':poster' => $data['poster'] ?? '',
':streaming' => $data['streaming'] ?? '',
]);
} else {
$stmt = $pdo->prepare(
"REPLACE INTO videotheque
(id, title, year, director, poster, format, length, publisher, ean_isbn13, number_of_discs, aspect_ratio, description)
VALUES (:id, :title, :year, :director, :poster, :format, :length, :publisher, :ean_isbn13, :number_of_discs, :aspect_ratio, :description)"
);
$stmt->execute([
':id' => $id,
':title' => $data['title'] ?? '',
':year' => $data['year'] ?? '',
':director' => $data['director'] ?? '',
':poster' => $data['poster'] ?? '',
':format' => $data['format'] ?? '',
':length' => !empty($data['length']) ? intval($data['length']) : null,
':publisher' => $data['publisher'] ?? '',
':ean_isbn13' => $data['ean_isbn13'] ?? '',
':number_of_discs' => !empty($data['number_of_discs']) ? intval($data['number_of_discs']) : 1,
':aspect_ratio' => $data['aspect_ratio'] ?? '',
':description' => $data['description'] ?? '',
]);
}
echo json_encode(["success" => true]);
}
// ── Sauvegarde clé TMDB ───────────────────────────────
elseif ($action === 'save_tmdb_key') {
checkAuth($pdo);
$stmt = $pdo->prepare(
"REPLACE INTO config (key_name, key_value) VALUES ('tmdb_api_key', :val)"
);
$stmt->execute([':val' => encryptData($data['tmdb_key'] ?? '')]);
echo json_encode(["success" => true]);
}
// ── Init compte admin ─────────────────────────────────
elseif ($action === 'setup_admin') {
$pwd = $data['password'] ?? '';
if (strlen($pwd) < 4) {
http_response_code(400);
echo json_encode(["error" => "Mot de passe trop court (min. 4 caractères)."]);
exit;
}
$stmt = $pdo->prepare(
"REPLACE INTO users (id, username, password_hash) VALUES (1, 'admin', :pass)"
);
$stmt->execute([':pass' => password_hash($pwd, PASSWORD_BCRYPT)]);
echo json_encode(["success" => "Compte admin initialisé."]);
}
// ── Changement mot de passe ───────────────────────────
elseif ($action === 'update_password') {
checkAuth($pdo);
$newPwd = $data['new_password'] ?? '';
if (strlen($newPwd) < 4) {
http_response_code(400);
echo json_encode(["error" => "Mot de passe trop court (min. 4 caractères)."]);
exit;
}
$stmt = $pdo->prepare(
"REPLACE INTO users (id, username, password_hash) VALUES (1, 'admin', :pass)"
);
$stmt->execute([':pass' => password_hash($newPwd, PASSWORD_BCRYPT)]);
echo json_encode(["success" => "Mot de passe mis à jour."]);
}
break;
// ════════════════════════════════════════════════════════════
case 'DELETE':
if ($action === 'delete_film') {
checkAuth($pdo);
$id = $_GET['id'] ?? 0;
$type = $_GET['type'] ?? 'critique';
$table = ($type === 'videotheque') ? 'videotheque' : 'critiques';
$stmt = $pdo->prepare("DELETE FROM {$table} WHERE id = :id");
$stmt->execute([':id' => $id]);
echo json_encode(["success" => true]);
}
elseif ($action === 'delete_multiple_films') {
checkAuth($pdo);
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$ids = $input['ids'] ?? [];
$type = $_GET['type'] ?? 'critique';
$table = ($type === 'videotheque') ? 'videotheque' : 'critiques';
if (empty($ids) || !is_array($ids)) {
http_response_code(400);
echo json_encode(["error" => "Aucun identifiant fourni."]);
exit;
}
$ids = array_map('intval', $ids);
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("DELETE FROM {$table} WHERE id IN ({$placeholders})");
$stmt->execute($ids);
echo json_encode(["success" => true, "deleted_count" => $stmt->rowCount()]);
}
break;
}
+390 -115
View File
@@ -1,145 +1,420 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--gold: #C9A84C; --gold-light: #E8C97A;
--dark: #0E0E0E; --surface: #1A1A1A; --surface2: #252525;
--text: #F0ECE3; --muted: #A8A4A0; --border: rgba(201,168,76,0.2);
--red: #c0392b;
}
body { background: var(--dark); color: var(--text); font-family: 'DM Sans', sans-serif; min-height: 100vh; }
/* ══════════════════════════════════════════════════════════════
admin.css — Backoffice Mon Cinéma
══════════════════════════════════════════════════════════════ */
/* ── PAGES ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--gold: #C9A84C;
--gold-light: #E8C97A;
--gold-dim: rgba(201, 168, 76, 0.08);
--dark: #0E0E0E;
--surface: #1A1A1A;
--surface2: #252525;
--text: #F0ECE3;
--text-secondary: #C8C4BE;
--muted: #A8A4A0;
--border: rgba(201, 168, 76, 0.2);
--red: #c0392b;
}
body {
background: var(--dark);
color: var(--text);
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
min-height: 100vh;
padding: 2rem 1rem 4rem;
-webkit-font-smoothing: antialiased;
}
/* ── Visibilité des pages ── */
.page { display: none; }
.page.active { display: block; }
/* ── SITE PUBLIC ── */
.site-wrap { max-width: 920px; margin: 0 auto; padding: 2rem 1.2rem 4rem; }
header { text-align: center; padding: 3rem 0 2rem; border-bottom: 1px solid var(--border); margin-bottom: 2.5rem; }
header::before { content: ''; display: block; width: 60px; height: 2px; background: var(--gold); margin: 0 auto 1.5rem; }
.site-title { font-family: 'Playfair Display', serif; font-size: 2.8rem; letter-spacing: 0.05em; }
.site-title span { color: var(--gold); font-style: italic; }
.site-subtitle { font-size: 0.78rem; letter-spacing: 0.25em; color: var(--muted); text-transform: uppercase; margin-top: 0.6rem; }
.pub-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.8rem; }
.count { font-size: 0.78rem; letter-spacing: 0.1em; color: #C0BCB8; text-transform: uppercase; }
/* ══════════════════════════════════════════════════════════════
LAYOUT ADMIN
══════════════════════════════════════════════════════════════ */
.admin-wrap { max-width: 960px; margin: 0 auto; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1.5rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; cursor: pointer; transition: transform 0.2s, border-color 0.2s; }
.card:hover { transform: translateY(-5px); border-color: var(--gold); }
.poster-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; background: var(--surface2); }
.poster-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
.poster-ph { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 0.5rem; color: var(--muted); font-size: 0.75rem; }
.poster-ph i { font-size: 2.5rem; }
.rating-badge { position: absolute; top: 8px; right: 8px; background: rgba(14,14,14,0.85); border: 1px solid var(--gold); color: var(--gold); font-family: 'Playfair Display', serif; font-size: 1rem; font-weight: 700; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.card-body { padding: 0.9rem 1rem 1rem; }
.card-title { font-family: 'Playfair Display', serif; font-size: 1rem; line-height: 1.3; margin-bottom: 0.2rem; }
.card-year { font-size: 0.75rem; color: #B8B4B0; margin-bottom: 0.4rem; }
.card-stars { color: var(--gold); font-size: 0.8rem; letter-spacing: 0.1em; margin-bottom: 0.4rem; }
.card-excerpt { font-size: 0.78rem; color: #C0BCB8; line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 1.2rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1.8rem;
flex-wrap: wrap;
gap: 1rem;
}
.empty-state { text-align: center; padding: 5rem 1rem; color: #C8C4C0; }
.empty-state i { font-size: 3.5rem; display: block; margin-bottom: 1rem; opacity: 0.3; }
.empty-state p { font-size: 0.95rem; line-height: 1.7; }
.admin-title { font-family: 'Playfair Display', serif; font-size: 1.8rem; }
.admin-title span { color: var(--gold); font-style: italic; }
.admin-actions { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; }
footer { text-align: center; margin-top: 4rem; padding-top: 1.5rem; border-top: 1px solid var(--border); font-size: 0.75rem; color: var(--muted); letter-spacing: 0.1em; }
/* ══════════════════════════════════════════════════════════════
ONGLETS ADMIN
══════════════════════════════════════════════════════════════ */
.admin-tabs {
display: flex;
gap: 0.4rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.5rem;
}
/* ── BACKOFFICE ── */
.admin-wrap { max-width: 960px; margin: 0 auto; padding: 2rem 1.2rem 4rem; }
.admin-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 1.2rem; border-bottom: 1px solid var(--border); margin-bottom: 2rem; flex-wrap: wrap; gap: 1rem; }
.admin-title { font-family: 'Playfair Display', serif; font-size: 1.6rem; }
.admin-title span { color: var(--gold); }
.admin-actions { display: flex; gap: 0.7rem; align-items: center; }
.tab-btn {
background: transparent;
border: none;
color: var(--muted);
padding: 0.55rem 1.2rem;
font-family: 'DM Sans', sans-serif;
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 0.55rem 1.1rem; font-family: 'DM Sans', sans-serif; font-size: 0.82rem; font-weight: 500; cursor: pointer; border-radius: 2px; letter-spacing: 0.04em; transition: background 0.2s, color 0.2s; border: none; }
.btn-gold { background: var(--gold); color: var(--dark); }
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.03); }
.tab-btn.active { color: var(--gold); background: rgba(201,168,76,0.08); }
/* ══════════════════════════════════════════════════════════════
BOUTONS GÉNÉRAUX
══════════════════════════════════════════════════════════════ */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.55rem 1.1rem;
font-family: 'DM Sans', sans-serif;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
border-radius: 3px;
letter-spacing: 0.04em;
transition: background 0.2s, color 0.2s, border-color 0.2s;
border: none;
text-decoration: none;
}
.btn-gold { background: var(--gold); color: var(--dark); }
.btn-gold:hover { background: var(--gold-light); }
.btn-outline { background: transparent; border: 1px solid rgba(201,168,76,0.35); color: #C8C4BE; }
.btn-outline { background: transparent; border: 1px solid rgba(201,168,76,0.3); color: #C8C4BE; }
.btn-outline:hover { border-color: var(--gold); color: var(--gold); }
.btn-danger { background: transparent; border: 1px solid #555; color: #C0BBBB; }
.btn-danger { background: transparent; border: 1px solid #555; color: #C0BBBB; }
.btn-danger:hover { border-color: var(--red); color: var(--red); }
/* Admin table */
.btn-primary { background: var(--gold); color: var(--dark); font-weight: 600; }
.btn-primary:hover { background: var(--gold-light); }
/* ══════════════════════════════════════════════════════════════
TABLEAU
══════════════════════════════════════════════════════════════ */
.admin-table-wrap { overflow-x: auto; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.admin-table th { text-align: left; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: #B8B4B0; padding: 0.6rem 0.8rem; border-bottom: 1px solid var(--border); font-weight: 600; }
.admin-table td { padding: 0.75rem 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.04); vertical-align: middle; }
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.admin-table th {
text-align: left;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #B8B4B0;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border);
font-weight: 600;
}
.admin-table td {
padding: 0.75rem 0.8rem;
border-bottom: 1px solid rgba(255,255,255,0.04);
vertical-align: middle;
}
.admin-table tr:hover td { background: var(--surface2); }
.thumb { width: 36px; height: 54px; object-fit: cover; border-radius: 2px; display: block; background: var(--surface2); }
.thumb-ph { width: 36px; height: 54px; background: var(--surface2); border-radius: 2px; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 1rem; }
.tbl-stars { color: var(--gold); font-size: 0.75rem; letter-spacing: 0.05em; }
.tbl-actions { display: flex; gap: 0.5rem; }
.tbl-actions button { background: none; border: none; cursor: pointer; color: #B0ACAA; font-size: 1rem; padding: 4px; border-radius: 2px; transition: color 0.15s; }
.tbl-actions button:hover { color: var(--gold); }
.tbl-actions button.del:hover { color: var(--red); }
/* ── MODAL ── */
.overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.88); z-index: 200; align-items: center; justify-content: center; padding: 1rem; }
.thumb {
width: 36px; height: 54px;
object-fit: cover;
border-radius: 3px;
display: block;
border: 1px solid var(--border);
}
.thumb-ph {
width: 36px; height: 54px;
background: var(--surface2);
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 1rem;
border: 1px dashed var(--border);
}
.tbl-stars { color: var(--gold); font-size: 0.8rem; letter-spacing: 0.05em; }
.tbl-actions { display: flex; gap: 0.4rem; justify-content: center; }
.tbl-actions button {
background: none;
border: 1px solid #444;
cursor: pointer;
color: #B0ACAA;
font-size: 0.9rem;
padding: 5px 7px;
border-radius: 3px;
transition: color 0.15s, border-color 0.15s;
line-height: 1;
}
.tbl-actions button:hover { color: var(--gold); border-color: var(--gold); }
.tbl-actions button.del:hover { color: var(--red); border-color: var(--red); }
.badge-format {
background: var(--gold-dim);
color: var(--gold);
font-size: 0.7rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 3px;
border: 1px solid rgba(201,168,76,0.25);
text-transform: uppercase;
}
/* ══════════════════════════════════════════════════════════════
BARRE D'ACTIONS EN MASSE
══════════════════════════════════════════════════════════════ */
.bulk-actions-bar {
background: var(--gold-dim);
border: 1px solid rgba(201,168,76,0.3);
padding: 0.8rem 1.2rem;
border-radius: 6px;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
/* ══════════════════════════════════════════════════════════════
BANNIÈRE SÉCURITÉ
══════════════════════════════════════════════════════════════ */
.security-banner {
background: #7f1d1d;
border: 1px solid #f87171;
color: #fca5a5;
padding: 1rem 1.2rem;
border-radius: 6px;
margin-bottom: 1.5rem;
font-size: 0.85rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.security-banner button {
background: #f87171;
color: #7f1d1d;
border: none;
padding: 0.4rem 0.9rem;
font-weight: 700;
border-radius: 3px;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.security-banner button:hover { background: #fca5a5; }
/* ══════════════════════════════════════════════════════════════
MODALES — Overlay + panneau
══════════════════════════════════════════════════════════════ */
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.88);
backdrop-filter: blur(6px);
z-index: 500;
align-items: center;
justify-content: center;
padding: 1rem;
overflow-y: auto;
}
.overlay.open { display: flex; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; width: 100%; max-width: 540px; max-height: 92vh; overflow-y: auto; padding: 2rem; position: relative; }
.modal-close { position: absolute; top: 1rem; right: 1rem; background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1.3rem; line-height: 1; }
.modal-close:hover { color: var(--text); }
.modal-h { font-family: 'Playfair Display', serif; font-size: 1.35rem; color: var(--gold); margin-bottom: 1.4rem; }
/* Film search */
.search-row { display: flex; gap: 0.6rem; margin-bottom: 1rem; }
.search-row input { flex: 1; background: var(--surface2); border: 1px solid rgba(255,255,255,0.1); color: var(--text); font-family: 'DM Sans', sans-serif; font-size: 0.9rem; padding: 0.65rem 0.8rem; border-radius: 3px; outline: none; }
.search-row input:focus { border-color: var(--gold); }
.btn-search { background: var(--gold); color: var(--dark); border: none; padding: 0 1rem; border-radius: 3px; cursor: pointer; font-size: 1rem; transition: background 0.2s; }
.btn-search:hover { background: var(--gold-light); }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
width: 100%;
max-width: 540px;
max-height: 92vh;
overflow-y: auto;
padding: 2rem;
position: relative;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.search-results { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 0.7rem; margin-bottom: 1.2rem; max-height: 220px; overflow-y: auto; }
.sr-item { cursor: pointer; border: 2px solid transparent; border-radius: 3px; overflow: hidden; transition: border-color 0.15s; }
.sr-item:hover, .sr-item.selected { border-color: var(--gold); }
.sr-item img { width: 100%; aspect-ratio: 2/3; object-fit: cover; display: block; }
.sr-item p { font-size: 0.65rem; color: var(--muted); padding: 3px 4px; line-height: 1.3; text-align: center; background: var(--surface2); }
.search-hint { font-size: 0.78rem; color: #B8B4B0; margin-bottom: 1rem; }
.searching { font-size: 0.8rem; color: var(--muted); text-align: center; padding: 1rem 0; }
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.modal-close:hover { color: var(--text); background: rgba(255,255,255,0.08); }
.modal-h {
font-family: 'Playfair Display', serif;
font-size: 1.4rem;
color: var(--gold);
margin-bottom: 1.4rem;
}
/* ══════════════════════════════════════════════════════════════
FORMULAIRES DANS LES MODALES
══════════════════════════════════════════════════════════════ */
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; color: #C0BCB8; margin-bottom: 0.35rem; }
.form-group input, .form-group textarea { width: 100%; background: var(--surface2); border: 1px solid rgba(255,255,255,0.1); color: var(--text); font-family: 'DM Sans', sans-serif; font-size: 0.9rem; padding: 0.65rem 0.8rem; border-radius: 3px; outline: none; transition: border-color 0.2s; }
.form-group input:focus, .form-group textarea:focus { border-color: var(--gold); }
.form-group label {
display: block;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #C0BCB8;
margin-bottom: 0.35rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
background: var(--surface2);
border: 1px solid rgba(255,255,255,0.1);
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-size: 0.9rem;
padding: 0.65rem 0.8rem;
border-radius: 4px;
outline: none;
transition: border-color 0.2s;
appearance: auto;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus { border-color: var(--gold); }
.form-group textarea { resize: vertical; min-height: 100px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.star-select { display: flex; gap: 6px; }
.star-select button { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--muted); transition: color 0.15s, transform 0.1s; padding: 2px; line-height: 1; }
.star-select button.active { color: var(--gold); }
.star-select button:hover { transform: scale(1.15); }
.poster-preview { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.poster-preview img { width: 60px; height: 90px; object-fit: cover; border-radius: 3px; border: 1px solid var(--border); }
.poster-preview-none { width: 60px; height: 90px; background: var(--surface2); border-radius: 3px; border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 1.3rem; }
.poster-preview-info { font-size: 0.8rem; color: #B8B4B0; line-height: 1.6; }
.poster-preview-info strong { color: var(--text); font-weight: 500; }
.btn-save { width: 100%; background: var(--gold); color: var(--dark); border: none; padding: 0.8rem; font-family: 'DM Sans', sans-serif; font-size: 0.9rem; font-weight: 500; cursor: pointer; border-radius: 3px; margin-top: 0.5rem; transition: background 0.2s; }
.btn-save {
width: 100%;
background: var(--gold);
color: var(--dark);
border: none;
padding: 0.8rem;
font-family: 'DM Sans', sans-serif;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
border-radius: 4px;
margin-top: 0.8rem;
transition: background 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-save:hover { background: var(--gold-light); }
.divider { border: none; border-top: 1px solid var(--border); margin: 1.2rem 0; }
/* Detail overlay */
.detail-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.92); z-index: 200; align-items: flex-start; justify-content: center; overflow-y: auto; padding: 3rem 1rem; }
.detail-overlay.open { display: flex; }
.detail-modal { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; width: 100%; max-width: 680px; padding: 2rem; position: relative; display: grid; grid-template-columns: 160px 1fr; gap: 2rem; align-items: start; }
@media(max-width:520px){ .detail-modal { grid-template-columns: 1fr; } }
.detail-poster { aspect-ratio: 2/3; background: var(--surface2); border-radius: 4px; overflow: hidden; }
.detail-poster img { width: 100%; height: 100%; object-fit: cover; display: block; }
.detail-title { font-family: 'Playfair Display', serif; font-size: 1.7rem; line-height: 1.2; margin-bottom: 0.3rem; }
.detail-meta { font-size: 0.8rem; color: #C0BCB8; margin-bottom: 0.8rem; }
.detail-stars { color: var(--gold); font-size: 1.1rem; letter-spacing: 0.15em; margin-bottom: 1.2rem; }
.detail-review { font-size: 0.95rem; line-height: 1.85; color: #D0CBC1; font-family: 'Playfair Display', serif; font-style: italic; }
.detail-review::before { content: '\201C'; font-size: 3.5rem; color: var(--gold); line-height: 0; vertical-align: -1rem; margin-right: 0.15rem; opacity: 0.6; }
/* ══════════════════════════════════════════════════════════════
API NOTICE
══════════════════════════════════════════════════════════════ */
.api-notice {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.9rem 1.1rem;
margin-bottom: 1.2rem;
font-size: 0.82rem;
color: #C0BCB8;
line-height: 1.6;
}
.api-notice a { color: var(--gold); }
/* Admin login */
.login-wrap { max-width: 360px; margin: 6rem auto; padding: 1.5rem; }
.login-wrap h2 { font-family: 'Playfair Display', serif; color: var(--gold); margin-bottom: 1.5rem; font-size: 1.4rem; }
.login-wrap input { width: 100%; background: var(--surface2); border: 1px solid rgba(255,255,255,0.1); color: var(--text); font-family: 'DM Sans', sans-serif; font-size: 0.9rem; padding: 0.65rem 0.8rem; border-radius: 3px; outline: none; margin-bottom: 1rem; }
/* ══════════════════════════════════════════════════════════════
PAGE LOGIN
══════════════════════════════════════════════════════════════ */
.login-wrap {
max-width: 360px;
margin: 7rem auto;
padding: 2rem 1.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
}
.login-wrap h2 {
font-family: 'Playfair Display', serif;
color: var(--gold);
margin-bottom: 1.8rem;
font-size: 1.6rem;
}
.login-wrap input {
width: 100%;
background: var(--surface2);
border: 1px solid rgba(255,255,255,0.1);
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-size: 0.9rem;
padding: 0.7rem 0.85rem;
border-radius: 4px;
outline: none;
margin-bottom: 1rem;
transition: border-color 0.2s;
}
.login-wrap input:focus { border-color: var(--gold); }
.login-err { color: var(--red); font-size: 0.82rem; margin-bottom: 0.8rem; display: none; }
/* TMDB badge */
.tmdb-note { font-size: 0.72rem; color: var(--muted); margin-top: 0.4rem; }
.tmdb-note a { color: var(--gold); text-decoration: none; }
.login-err {
color: var(--red);
font-size: 0.82rem;
margin-bottom: 0.8rem;
display: none;
}
/* API key notice */
.api-notice { background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: 1rem 1.2rem; margin-bottom: 1.5rem; font-size: 0.82rem; color: #C0BCB8; line-height: 1.6; }
.api-notice strong { color: var(--text); }
.api-notice a { color: var(--gold); }
/* ══════════════════════════════════════════════════════════════
RESPONSIVE
══════════════════════════════════════════════════════════════ */
@media (max-width: 600px) {
.form-row { grid-template-columns: 1fr; }
.admin-header { flex-direction: column; align-items: flex-start; }
.admin-actions { width: 100%; }
.modal { padding: 1.5rem; }
}
+2 -1
View File
@@ -178,7 +178,8 @@ body {
.card-poster {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: cover; /* Garantit que l'affiche remplit harmonieusement la carte */
display: block; /* Supprime les espaces blancs sous l'image */
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
+22
View File
@@ -0,0 +1,22 @@
<?php
// Sécurité : On s'assure que la requête vient bien de la machine locale (Gitea)
$local_ips = array('127.0.0.1', '::1');
if (!in_array($_SERVER['REMOTE_ADDR'], $local_ips)) {
header('HTTP/1.1 403 Forbidden');
die('Accès refusé.');
}
// 1. Définir le chemin absolu vers le dossier de votre site (adapter si nécessaire)
$site_dir = 'C:\\wamp64\\www\\mon-petit-cinema';
// 2. Commande Windows pour changer de disque/dossier ET lancer le git pull
// Le "2>&1" permet de capturer les erreurs Windows s'il y en a.
$command = "cmd.exe /c cd /d " . escapeshellarg($site_dir) . " && git pull origin main 2>&1";
// 3. Exécution de la commande
$output = shell_exec($command);
// Affichage du résultat dans les logs du webhook Gitea
echo "Mise à jour Windows Git effectuée :\n";
echo $output;
?>
+13 -22
View File
@@ -22,7 +22,7 @@
<button id="tab-pub-critiques" class="tab-btn active" onclick="switchPubTab('critique')">
<i class="ti ti-message-star"></i> Mes Critiques
</button>
<button id="tab-pub-videotheque" class="tab-btn" onclick="switchPubTab('videotheque')">
<button id="tab-pub-videotheques" class="tab-btn" onclick="switchPubTab('videotheque')">
<i class="ti ti-device-tv"></i> Ma Vidéothèque
</button>
</div>
@@ -31,26 +31,20 @@
<div class="rating-filter-bar" id="rating-filter-bar">
<span class="rf-label">Filtrer :</span>
<div class="rf-buttons">
<button class="rating-filter-btn" data-stars="1" onclick="filterByRating(1)" title="1 étoile">
<span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span>
</button>
<button class="rating-filter-btn" data-stars="2" onclick="filterByRating(2)" title="2 étoiles">
<span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span>
</button>
<button class="rating-filter-btn" data-stars="3" onclick="filterByRating(3)" title="3 étoiles">
<span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span>
</button>
<button class="rating-filter-btn" data-stars="4" onclick="filterByRating(4)" title="4 étoiles">
<span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span>
</button>
<button class="rating-filter-btn" data-stars="5" onclick="filterByRating(5)" title="5 étoiles">
<button class="rating-filter-btn" data-stars="5" onclick="filterByRating(5)">
<span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span>
</button>
<button class="rating-filter-btn" data-stars="4" onclick="filterByRating(4)">
<span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span>
</button>
<button class="rating-filter-btn" data-stars="3" onclick="filterByRating(3)">
<span class="rf-star"></span><span class="rf-star"></span><span class="rf-star"></span>
</button>
</div>
</div>
<div class="pub-toolbar">
<span class="count" id="count-label">0 critique</span>
<span class="count" id="count-label">0 élément</span>
<a href="admin/login.html" class="btn-admin-link">
<i class="ti ti-settings"></i> Administration
</a>
@@ -60,8 +54,9 @@
<div class="empty-state" id="empty-state" style="display:none">
<i class="ti ti-movie" aria-hidden="true"></i>
<p>Aucune critique pour l'instant.</p>
<p>Aucun contenu trouvé dans cette catégorie.</p>
</div>
<footer>Mon Cinéma &mdash; Journal personnel</footer>
</div>
</div>
@@ -69,19 +64,15 @@
<div class="detail-overlay" id="detail-overlay" role="dialog" aria-modal="true" onclick="if(event.target===this) closeDetail()">
<div class="detail-modal" id="detail-modal-layout">
<button class="modal-close" onclick="closeDetail()" aria-label="Fermer"><i class="ti ti-x"></i></button>
<div class="detail-poster" id="d-poster-wrap">
<img id="d-poster" src="" alt="Affiche du film" />
<img id="d-poster" src="" alt="Affiche" />
</div>
<div class="detail-content">
<header class="detail-header-block">
<h2 class="detail-title" id="d-title"></h2>
<p class="detail-meta" id="d-meta"></p>
<div class="detail-stars" id="d-stars"></div>
</header>
<div class="detail-dynamic-body" id="d-review"></div>
<div id="d-body"></div>
</div>
</div>
</div>
+393 -1
View File
@@ -1,3 +1,4 @@
<<<<<<< HEAD
/**
* =========================================================================
* Mon Cinéma - Module d'Administration (admin.js)
@@ -279,4 +280,395 @@ function switchTab(tabName) {
if (targetBtn) targetBtn.classList.add('active');
localStorage.setItem('mon-cinema-active-tab', tabName);
}
}
=======
// ══════════════════════════════════════════════════════════════════
// admin.js — Backoffice Mon Cinéma
// Chargé UNIQUEMENT par dashboard.html
// ══════════════════════════════════════════════════════════════════
const API_URL = '../api.php';
let allItems = [];
let currentAdminTab = 'critique';
// ── Garde de session (dashboard uniquement) ──────────────────────
(function guardSession() {
if (!localStorage.getItem('token')) {
window.location.href = 'login.html';
}
})();
// ══════════════════════════════════════════════════════════════════
// 1. CHARGEMENT & RENDU
// ══════════════════════════════════════════════════════════════════
async function loadDashboardData() {
try {
const res = await fetch(`${API_URL}?action=get_films`);
allItems = await res.json();
// Vérifier si compte sécurisé
const secRes = await fetch(`${API_URL}?action=check_security_status`);
const secData = await secRes.json();
const banner = document.getElementById('security-banner');
if (banner) banner.style.display = secData.is_blank ? 'flex' : 'none';
renderAdminTable();
} catch (err) {
console.error('Erreur chargement :', err);
}
}
function switchAdminTab(tabName) {
currentAdminTab = tabName;
document.getElementById('btn-tab-critique').classList.toggle('active', tabName === 'critique');
document.getElementById('btn-tab-videotheque').classList.toggle('active', tabName === 'videotheque');
const importSection = document.getElementById('import-section');
if (importSection) importSection.style.display = tabName === 'critique' ? 'block' : 'none';
document.getElementById('admin-subtitle').textContent =
tabName === 'critique'
? 'Gestion de vos critiques de films'
: 'Gestion de votre stock physique de films';
document.getElementById('th-dynamic').textContent = tabName === 'critique' ? 'Note' : 'Format';
const selectAll = document.getElementById('select-all-checkbox');
if (selectAll) selectAll.checked = false;
renderAdminTable();
}
function renderAdminTable() {
const tbody = document.getElementById('admin-table-body');
const countLabel = document.getElementById('admin-count-label');
if (!tbody) return;
tbody.innerHTML = '';
const filtered = allItems.filter(item => item.type === currentAdminTab);
if (countLabel) {
countLabel.textContent = `${filtered.length} élément(s) dans cette catégorie`;
}
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center; color:var(--muted); padding:3rem;">
<i class="ti ti-folder-off" style="font-size:2rem; display:block; margin-bottom:0.5rem;"></i>
Aucun film trouvé dans cette liste.</td></tr>`;
updateBulkBarVisibility();
return;
}
filtered.forEach(f => {
const tr = document.createElement('tr');
const imgHTML = f.poster
? `<img src="${f.poster}" class="thumb" alt="${f.title}">`
: `<div class="thumb-ph"><i class="ti ti-photo-off"></i></div>`;
const dynamicCell = currentAdminTab === 'critique'
? `<span class="tbl-stars">${'★'.repeat(f.rating)}<span style="opacity:.25">${'☆'.repeat(5 - f.rating)}</span></span>`
: `<span class="badge-format">${f.format || 'Physique'}</span>`;
tr.innerHTML = `
<td style="text-align:center;">
<input type="checkbox" class="film-checkbox" value="${f.id}" onclick="updateBulkBarVisibility()">
</td>
<td style="text-align:center;">${imgHTML}</td>
<td><strong style="color:var(--text);">${f.title}</strong></td>
<td style="color:var(--text-secondary);">${f.year || '&mdash;'}</td>
<td style="color:var(--text-secondary); font-style:italic;">${f.director || 'Inconnu'}</td>
<td>${dynamicCell}</td>
<td style="text-align:center;">
<div class="tbl-actions">
<button onclick="openEditModal('${f.id}')" title="Modifier"><i class="ti ti-edit"></i></button>
<button class="del" onclick="deleteSingleFilm('${f.id}')" title="Supprimer"><i class="ti ti-trash"></i></button>
</div>
</td>`;
tbody.appendChild(tr);
});
updateBulkBarVisibility();
}
// ══════════════════════════════════════════════════════════════════
// 2. SÉLECTION EN MASSE
// ══════════════════════════════════════════════════════════════════
function toggleSelectAll(master) {
document.querySelectorAll('.film-checkbox').forEach(cb => cb.checked = master.checked);
updateBulkBarVisibility();
}
function updateBulkBarVisibility() {
const checked = document.querySelectorAll('.film-checkbox:checked');
const bulkBar = document.getElementById('bulk-actions-bar');
const bulkCount = document.getElementById('bulk-count');
const selectAll = document.getElementById('select-all-checkbox');
if (!bulkBar || !bulkCount) return;
if (checked.length > 0) {
bulkBar.style.display = 'flex';
bulkCount.textContent = checked.length;
} else {
bulkBar.style.display = 'none';
if (selectAll) selectAll.checked = false;
}
}
async function executeBulkDelete() {
const checked = document.querySelectorAll('.film-checkbox:checked');
const idsToDelete = Array.from(checked).map(cb => cb.value);
if (!idsToDelete.length) return;
if (!confirm(`Supprimer définitivement ces ${idsToDelete.length} élément(s) ?`)) return;
try {
const res = await fetch(`${API_URL}?action=delete_multiple_films&type=${currentAdminTab}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'Authorization': localStorage.getItem('token') },
body: JSON.stringify({ ids: idsToDelete })
});
const data = await res.json();
if (data.success) {
const selectAll = document.getElementById('select-all-checkbox');
if (selectAll) selectAll.checked = false;
loadDashboardData();
} else {
alert('Erreur suppression groupée : ' + data.error);
}
} catch (err) {
console.error('Bulk delete :', err);
}
}
// ══════════════════════════════════════════════════════════════════
// 3. SUPPRESSION UNITAIRE
// ══════════════════════════════════════════════════════════════════
async function deleteSingleFilm(id) {
if (!confirm('Supprimer définitivement cette œuvre ?')) return;
try {
const res = await fetch(`${API_URL}?action=delete_film&id=${id}&type=${currentAdminTab}`, {
method: 'DELETE',
headers: { 'Authorization': localStorage.getItem('token') }
});
const data = await res.json();
if (data.success) loadDashboardData();
} catch (err) {
console.error(err);
}
}
// ══════════════════════════════════════════════════════════════════
// 4. MODALES FILM (ouvrir / fermer / remplir)
// ══════════════════════════════════════════════════════════════════
function openAddModal() {
document.getElementById('film-form').reset();
document.getElementById('f-id').value = '';
document.getElementById('modal-form-title').textContent =
currentAdminTab === 'critique' ? 'Rédiger une Critique' : 'Ajouter un film physique';
document.getElementById('form-critique-fields').style.display = currentAdminTab === 'critique' ? 'block' : 'none';
document.getElementById('form-videotheque-fields').style.display = currentAdminTab === 'videotheque' ? 'block' : 'none';
document.getElementById('admin-modal').classList.add('open');
}
function openEditModal(id) {
const item = allItems.find(x => String(x.id) === String(id));
if (!item) return;
openAddModal();
document.getElementById('modal-form-title').textContent = "Modifier l'œuvre";
document.getElementById('f-id').value = item.id;
document.getElementById('f-title').value = item.title || '';
document.getElementById('f-year').value = item.year || '';
document.getElementById('f-director').value = item.director || '';
document.getElementById('f-poster').value = item.poster || '';
if (currentAdminTab === 'critique') {
document.getElementById('f-rating').value = item.rating || 3;
document.getElementById('f-review').value = item.review || '';
document.getElementById('f-streaming').value = item.streaming || '';
} else {
document.getElementById('f-format').value = item.format || '';
document.getElementById('f-length').value = item.length || '';
document.getElementById('f-publisher').value = item.publisher || '';
document.getElementById('f-aspect').value = item.aspect_ratio || '';
document.getElementById('f-ean').value = item.ean_isbn13 || '';
document.getElementById('f-discs').value = item.number_of_discs || 1;
document.getElementById('f-description').value = item.description || '';
}
}
function closeAdminModal() {
document.getElementById('admin-modal').classList.remove('open');
}
// ══════════════════════════════════════════════════════════════════
// 5. SAUVEGARDE FORMULAIRE FILM
// ══════════════════════════════════════════════════════════════════
async function saveFilmForm(e) {
e.preventDefault();
const payload = {
type: currentAdminTab,
id: document.getElementById('f-id').value || null,
title: document.getElementById('f-title').value,
year: document.getElementById('f-year').value,
director: document.getElementById('f-director').value,
poster: document.getElementById('f-poster').value
};
if (currentAdminTab === 'critique') {
payload.rating = parseInt(document.getElementById('f-rating').value);
payload.review = document.getElementById('f-review').value;
payload.streaming = document.getElementById('f-streaming').value;
} else {
payload.format = document.getElementById('f-format').value;
payload.length = document.getElementById('f-length').value;
payload.publisher = document.getElementById('f-publisher').value;
payload.aspect_ratio = document.getElementById('f-aspect').value;
payload.ean_isbn13 = document.getElementById('f-ean').value;
payload.number_of_discs = document.getElementById('f-discs').value;
payload.description = document.getElementById('f-description').value;
}
try {
const res = await fetch(`${API_URL}?action=save_film`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': localStorage.getItem('token') },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success) {
closeAdminModal();
loadDashboardData();
} else {
alert('Erreur lors de l\'enregistrement.');
}
} catch (err) {
console.error('Erreur save :', err);
}
}
// ══════════════════════════════════════════════════════════════════
// 6. IMPORT CSV (Letterboxd)
// ══════════════════════════════════════════════════════════════════
async function handleCsvUpload(input) {
if (!input.files || input.files.length === 0) return;
const formData = new FormData();
formData.append('csv_file', input.files[0]);
try {
const res = await fetch(`${API_URL}?action=import_csv`, {
method: 'POST',
headers: { 'Authorization': localStorage.getItem('token') },
body: formData
});
const data = await res.json();
if (data.success) {
alert(`✅ Import réussi ! ${data.imported} films ajoutés ou mis à jour.`);
loadDashboardData();
} else {
alert('❌ Erreur import : ' + (data.error || 'Erreur inconnue'));
}
} catch (err) {
console.error('Erreur Import CSV :', err);
alert('Impossible d\'importer le fichier.');
} finally {
input.value = '';
}
}
// ══════════════════════════════════════════════════════════════════
// 7. MODALE TMDB
// ══════════════════════════════════════════════════════════════════
function openConfigModal() { document.getElementById('config-modal').classList.add('open'); }
function closeConfigModal() { document.getElementById('config-modal').classList.remove('open'); }
async function saveTmdbKey() {
const key = document.getElementById('tmdb-key-input').value.trim();
if (!key) { alert('Veuillez saisir une clé.'); return; }
try {
const res = await fetch(`${API_URL}?action=save_tmdb_key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': localStorage.getItem('token') },
body: JSON.stringify({ tmdb_key: key })
});
const data = await res.json();
if (data.success) {
alert('✅ Clé TMDB chiffrée et sauvegardée.');
document.getElementById('tmdb-key-input').value = '';
closeConfigModal();
}
} catch (err) {
console.error(err);
}
}
// ══════════════════════════════════════════════════════════════════
// 8. MODALE MOT DE PASSE
// ══════════════════════════════════════════════════════════════════
function openPasswordModal() { document.getElementById('password-modal').classList.add('open'); }
function closePasswordModal() { document.getElementById('password-modal').classList.remove('open'); }
async function saveNewPassword() {
const pwd1 = document.getElementById('new-password-input').value;
const pwd2 = document.getElementById('new-password-confirm').value;
const errEl = document.getElementById('pwd-error');
errEl.style.display = 'none';
if (pwd1.length < 4) {
errEl.textContent = 'Le mot de passe doit faire au moins 4 caractères.';
errEl.style.display = 'block';
return;
}
if (pwd1 !== pwd2) {
errEl.textContent = 'Les mots de passe ne correspondent pas.';
errEl.style.display = 'block';
return;
}
try {
// Premier usage (compte vide) → setup_admin, sinon update_password
const secRes = await fetch(`${API_URL}?action=check_security_status`);
const secData = await secRes.json();
const action = secData.is_blank ? 'setup_admin' : 'update_password';
const res = await fetch(`${API_URL}?action=${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': localStorage.getItem('token') },
body: JSON.stringify({ password: pwd1, new_password: pwd1 })
});
const data = await res.json();
if (data.success || data.success === '') {
alert('✅ Mot de passe mis à jour. Vous allez être redirigé vers la connexion.');
localStorage.removeItem('token');
window.location.href = 'login.html';
}
} catch (err) {
console.error(err);
}
}
// ══════════════════════════════════════════════════════════════════
// 9. DÉCONNEXION
// ══════════════════════════════════════════════════════════════════
function logout() {
localStorage.removeItem('token');
window.location.href = 'login.html';
}
// ══════════════════════════════════════════════════════════════════
// INIT
// ══════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', loadDashboardData);
>>>>>>> 5a22e3f (all)
+101 -178
View File
@@ -1,45 +1,50 @@
const STORAGE_KEY = 'mon-cinema-films';
let films = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
let films = [];
const API_URL = '../api.php';
let currentPubTab = 'critique';
let activeRatingFilter = 0; // 0 = tous
let activeRatingFilter = 0;
async function loadPublicData() {
try {
const response = await fetch(`${API_URL}?action=get_films`);
films = await response.json();
renderPublicGrid();
} catch (error) {
console.error("Erreur de récupération :", error);
}
}
function switchPubTab(tabName) {
currentPubTab = tabName;
activeRatingFilter = 0;
const filterBar = document.getElementById('rating-filter-bar');
if(filterBar) {
filterBar.style.display = (tabName === 'critique') ? 'flex' : 'none';
}
document.querySelectorAll('.rating-filter-btn').forEach(btn => {
btn.classList.remove('active');
btn.querySelectorAll('.rf-star').forEach(s => s.classList.remove('filled'));
});
const tabCritiques = document.getElementById('tab-pub-critiques');
const tabVideotheque = document.getElementById('tab-pub-videotheque');
if (tabCritiques && tabVideotheque) {
if (tabName === 'critique') {
tabCritiques.classList.add('active');
tabVideotheque.classList.remove('active');
} else {
tabVideotheque.classList.add('active');
tabCritiques.classList.remove('active');
}
}
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
const activeBtn = document.getElementById(`tab-pub-${tabName}s`);
if(activeBtn) activeBtn.classList.add('active');
renderPublicGrid();
}
function filterByRating(stars) {
if (currentPubTab !== 'critique') return;
activeRatingFilter = (activeRatingFilter === stars) ? 0 : stars;
// Update button states
document.querySelectorAll('.rating-filter-btn').forEach(btn => {
const n = parseInt(btn.dataset.stars);
btn.classList.toggle('active', n === activeRatingFilter);
// Highlight filled stars up to active filter
btn.querySelectorAll('.rf-star').forEach((s, i) => {
s.classList.toggle('filled', activeRatingFilter > 0 && i < activeRatingFilter);
s.classList.toggle('filled', i < activeRatingFilter);
});
});
renderPublicGrid();
}
@@ -47,205 +52,126 @@ function renderPublicGrid() {
const grid = document.getElementById('grid');
const emptyState = document.getElementById('empty-state');
const countLabel = document.getElementById('count-label');
if (!grid) return;
// Show rating filter only on critique tab
const filterBar = document.getElementById('rating-filter-bar');
if (filterBar) filterBar.style.display = currentPubTab === 'critique' ? 'flex' : 'none';
grid.innerHTML = '';
const filtered = films.filter(f => {
const fType = f.type || 'critique';
if (fType !== currentPubTab) return false;
if (currentPubTab === 'critique' && activeRatingFilter > 0) {
return (f.rating || f.note || 1) === activeRatingFilter;
}
return true;
});
let filtered = films.filter(f => f.type === currentPubTab);
if (currentPubTab === 'critique' && activeRatingFilter > 0) {
filtered = filtered.filter(f => parseInt(f.rating) === activeRatingFilter);
}
if (countLabel) {
if (currentPubTab === 'critique') {
const suffix = activeRatingFilter > 0 ? ` · filtrées ${activeRatingFilter}` : '';
countLabel.textContent = filtered.length + (filtered.length > 1 ? ' critiques' : ' critique') + suffix;
} else {
countLabel.textContent = filtered.length + (filtered.length > 1 ? ' films physiques' : ' film physique');
}
countLabel.textContent = `${filtered.length} ${currentPubTab === 'critique' ? 'critique' : 'œuvre'}${filtered.length > 1 ? 's' : ''}`;
}
if (filtered.length === 0) {
grid.innerHTML = '';
if (emptyState) {
emptyState.style.display = 'block';
emptyState.querySelector('p').textContent = currentPubTab === 'critique'
? "Aucune critique pour l'instant."
: "Aucun film dans la vidéothèque pour l'instant.";
}
if (emptyState) emptyState.style.display = 'block';
return;
}
if (emptyState) emptyState.style.display = 'none';
grid.innerHTML = filtered.map(f => {
const posterUrl = f.poster || f.image || f.affiche || '';
const movieTitle = f.title || f.titre || 'Sans titre';
const movieYear = f.year || f.annee || f.publish_date || '—';
const movieDirector = f.director || f.creators || f.realisateur || '—';
filtered.forEach(f => {
const card = document.createElement('div');
card.className = 'card';
card.onclick = () => openDetail(f.id);
const posterHtml = posterUrl
? `<img src="${posterUrl}" alt="Affiche ${movieTitle}" class="card-poster">`
: `<div class="card-poster-placeholder">
<i class="ti ti-movie"></i>
<span>${movieTitle}</span>
</div>`;
let footerInfoHtml = '';
if (currentPubTab === 'critique') {
const rating = f.rating || f.note || 1;
footerInfoHtml = `<div class="card-stars">${"★".repeat(rating)}<span class="stars-muted">${"☆".repeat(5 - rating)}</span></div>`;
// Rendu de l'affiche ou du placeholder si absente
let posterHTML = '';
if (f.poster) {
posterHTML = `<div class="card-poster-wrap"><img class="card-poster" src="${f.poster}" alt="${f.title}" loading="lazy"></div>`;
} else {
const format = (f.format || f.support || 'DVD').toUpperCase().replace('_4K', ' 4K');
const length = f.length || f.duree || '';
footerInfoHtml = `
<div class="card-video-footer">
<span class="badge-format">${format}</span>
${length ? `<span class="video-length"><i class="ti ti-clock"></i> ${length} min</span>` : ''}
posterHTML = `
<div class="card-poster-wrap">
<div class="card-poster-placeholder">
<i class="ti ti-movie"></i>
<span>Pas d'affiche</span>
</div>
</div>`;
}
return `
<div class="card" onclick="openDetail(${f.id})">
<div class="card-poster-wrap">
${posterHtml}
</div>
if (f.type === 'critique') {
const starsHTML = '★'.repeat(f.rating) + `<span class="stars-muted">${'☆'.repeat(5 - f.rating)}</span>`;
card.innerHTML = `
${posterHTML}
<div class="card-body">
<h3 class="card-title">${movieTitle}</h3>
<div class="card-meta">${movieYear.toString().substring(0,4)} &middot; ${movieDirector}</div>
${footerInfoHtml}
<h3 class="card-title" title="${f.title}">${f.title}</h3>
<p class="card-meta">${f.year ? f.year + ' &middot; ' : ''}${f.director || 'Réalisateur inconnu'}</p>
<div class="card-stars">${starsHTML}</div>
</div>
</div>
`;
}).join('');
`;
} else {
card.innerHTML = `
${posterHTML}
<div class="card-body">
<h3 class="card-title" title="${f.title}">${f.title}</h3>
<p class="card-meta">${f.year ? f.year + ' &middot; ' : ''}${f.director || 'Réalisateur inconnu'}</p>
<div class="card-video-footer">
<span class="badge-format">${f.format || 'Physique'}</span>
${f.length ? `<span class="video-length"><i class="ti ti-clock"></i> ${f.length} min</span>` : ''}
</div>
</div>
`;
}
grid.appendChild(card);
});
}
function openDetail(id) {
const f = films.find(x => x.id === id);
const f = films.find(item => item.id == id);
if (!f) return;
const detailOverlay = document.getElementById('detail-overlay');
const modalLayout = document.getElementById('detail-modal-layout');
const dPoster = document.getElementById('d-poster');
const dPosterWrap = document.getElementById('d-poster-wrap');
const dTitle = document.getElementById('d-title');
const dMeta = document.getElementById('d-meta');
const dStars = document.getElementById('d-stars');
const dReview = document.getElementById('d-review');
const dBody = document.getElementById('d-body');
const detailModalLayout = document.getElementById('detail-modal-layout');
const detailOverlay = document.getElementById('detail-overlay');
const movieTitle = f.title || f.titre || 'Sans titre';
const movieYear = f.year || f.annee || f.publish_date || '—';
const movieDirector = f.director || f.creators || f.realisateur || '—';
const posterUrl = f.poster || f.image || f.affiche || '';
if (dTitle) dTitle.textContent = movieTitle;
if (dMeta) dMeta.textContent = [movieYear.toString().substring(0,4), movieDirector].filter(Boolean).join(' · ');
const fType = f.type || 'critique';
// Gestion de l'affichage de la colonne image
if (posterUrl) {
if (dPoster) { dPoster.src = posterUrl; dPoster.style.display = 'block'; }
// Gestion de la visibilité globale de l'affiche dans la modale
if (f.poster) {
if (dPoster) dPoster.src = f.poster;
if (dPosterWrap) {
dPosterWrap.style.display = 'block';
dPosterWrap.classList.remove('poster-critique', 'poster-video');
dPosterWrap.classList.add(fType === 'critique' ? 'poster-critique' : 'poster-video');
dPosterWrap.className = `detail-poster poster-${f.type}`;
}
if (modalLayout) modalLayout.classList.remove('no-poster');
if (detailModalLayout) detailModalLayout.classList.remove('no-poster');
} else {
if (dPoster) dPoster.style.display = 'none';
if (dPosterWrap) dPosterWrap.style.display = 'none';
if (modalLayout) modalLayout.classList.add('no-poster');
if (detailModalLayout) detailModalLayout.classList.add('no-poster');
}
// Préparation du badge de visionnage/streaming (commun aux deux types de fiches)
const streamingInfo = f.streaming || "Disponible au cinéma ou support physique";
const isPlatform = streamingInfo !== "Disponible au cinéma ou support physique";
const streamIcon = isPlatform ? "ti-device-tv" : "ti-movie";
const streamingBadgeHtml = `
<div style="margin-bottom: 1.2rem; display: inline-flex; align-items: center; gap: 0.5rem; background: ${isPlatform ? 'rgba(201, 168, 76, 0.1)' : 'rgba(255,255,255,0.03)'}; border: 1px solid ${isPlatform ? 'var(--gold)' : 'var(--border)'}; padding: 0.4rem 0.8rem; border-radius: 6px; font-size: 0.85rem; color: ${isPlatform ? 'var(--gold)' : '#c0c0cc'}; font-weight: 500;">
<i class="ti ${streamIcon}" style="font-size: 1.1rem;"></i>
<span>${streamingInfo}</span>
</div>
`;
if (fType === 'critique') {
// ----------------------------------------
// POP-UP MODE : CRITIQUE JOURNAL
// ----------------------------------------
if (dStars) {
dStars.style.display = 'block';
const rating = f.rating || f.note || 1;
dStars.innerHTML = "★".repeat(rating) + `<span class="stars-muted">${"☆".repeat(5 - rating)}</span>`;
}
if (dReview) {
const reviewText = f.review || f.critique || "Aucune critique rédigée pour ce film.";
dReview.innerHTML = `
${streamingBadgeHtml}
if (dTitle) dTitle.textContent = f.title;
if (dMeta) dMeta.textContent = `${f.year ? f.year + ' | ' : ''}${f.director || 'Réalisateur inconnu'}`;
if (dBody) {
if (f.type === 'critique') {
const stars = '★'.repeat(f.rating) + `<span class="stars-muted">${'☆'.repeat(5 - f.rating)}</span>`;
dBody.innerHTML = `
<div class="detail-stars">${stars}</div>
<div class="pub-review-card">
<div class="pub-review-quote-icon"><i class="ti ti-quote"></i></div>
<p class="pub-review-text">${reviewText}</p>
<p class="pub-review-text">${f.review ? f.review : 'Aucun texte rédigé pour le moment.'}</p>
</div>
${f.streaming ? `<div class="tech-pill" style="margin-top:1.5rem; width:fit-content;"><i class="ti ti-device-tv"></i> Visionnage : ${f.streaming}</div>` : ''}
`;
}
} else {
// ----------------------------------------
// POP-UP MODE : VIDEOTHEQUE PREMIUM
// ----------------------------------------
if (dStars) dStars.style.display = 'none';
const format = (f.format || f.support || 'DVD').toUpperCase().replace('_4K', ' 4K');
const publisher = f.publisher || f.editeur || '';
const length = f.length || f.duree || '';
const discs = f.number_of_discs || f.disques || f.nb_disques || '';
const ratio = f.aspect_ratio || f.format_image || '';
const ean = f.ean_isbn13 || f.ean || '';
const description = f.description || f.synopsis || "Aucun synopsis disponible dans le catalogue.";
if (dReview) {
dReview.innerHTML = `
${streamingBadgeHtml}
} else {
dBody.innerHTML = `
<div class="pub-tech-badges">
<span class="tech-pill format-gold"><i class="ti ti-disc"></i> ${format}</span>
${length ? `<span class="tech-pill"><i class="ti ti-clock"></i> ${length} Min</span>` : ''}
${discs ? `<span class="tech-pill"><i class="ti ti-layers-intersect"></i> ${discs} ${discs > 1 ? 'Disques' : 'Disque'}</span>` : ''}
${f.format ? `<span class="tech-pill format-gold"><i class="ti ti-disc"></i> ${f.format}</span>` : ''}
${f.length ? `<span class="tech-pill"><i class="ti ti-clock"></i> ${f.length} Min</span>` : ''}
${f.number_of_discs ? `<span class="tech-pill"><i class="ti ti-layers-intersect"></i> ${f.number_of_discs} Disque(s)</span>` : ''}
</div>
<div class="pub-tech-grid">
${publisher ? `
<div class="tech-meta-item">
<span class="tech-meta-label">Éditeur / Studio</span>
<span class="tech-meta-value">${publisher}</span>
</div>` : ''}
${ratio ? `
<div class="tech-meta-item">
<span class="tech-meta-label">Format d'image</span>
<span class="tech-meta-value" style="font-family: monospace;">${ratio}</span>
</div>` : ''}
${ean ? `
<div class="tech-meta-item" style="grid-column: span 2;">
<span class="tech-meta-label">Code-barres (EAN)</span>
<span class="tech-meta-value" style="font-family: monospace; color: var(--muted);">${ean}</span>
</div>` : ''}
<div class="tech-meta-item"><span class="tech-meta-label">Éditeur</span><span class="tech-meta-value">${f.publisher || '&mdash;'}</span></div>
<div class="tech-meta-item"><span class="tech-meta-label">Format Image</span><span class="tech-meta-value">${f.aspect_ratio || '&mdash;'}</span></div>
<div class="tech-meta-item" style="grid-column: span 2;"><span class="tech-meta-label">Code Barre (EAN)</span><span class="tech-meta-value">${f.ean_isbn13 || '&mdash;'}</span></div>
</div>
<div class="pub-synopsis-box">
<h4>Synopsis</h4>
<p>${description}</p>
<h4>Synopsis / Notes</h4>
<p>${f.description ? f.description : 'Aucune description fournie.'}</p>
</div>
`;
}
@@ -253,13 +179,10 @@ function openDetail(id) {
if (detailOverlay) detailOverlay.classList.add('open');
}
function closeDetail() {
const detailOverlay = document.getElementById('detail-overlay');
if (detailOverlay) {
detailOverlay.classList.remove('open');
}
if (detailOverlay) detailOverlay.classList.remove('open');
}
document.addEventListener('DOMContentLoaded', () => {
renderPublicGrid();
});
document.addEventListener('DOMContentLoaded', loadPublicData);