Compare commits

...

3 Commits

14 changed files with 1249 additions and 35 deletions

@ -0,0 +1,59 @@
<?php
require_once '../../includes/config.php';
require_once '../../includes/auth.php';
require_once '../../includes/stories.php';
// Vérification de l'authentification
if (!Auth::check()) {
http_response_code(401);
exit(json_encode(['success' => false, 'error' => 'Non autorisé']));
}
// Seul un administrateur peut modifier les autorisations d'accès
if (!Auth::isAdmin() && !Auth::hasAdminRole()) {
http_response_code(403);
exit(json_encode(['success' => false, 'error' => 'Accès refusé']));
}
// Vérification de la méthode HTTP
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit(json_encode(['success' => false, 'error' => 'Méthode non autorisée']));
}
// Récupération et validation des données
$input = json_decode(file_get_contents('php://input'), true);
$storyId = $input['storyId'] ?? null;
$access = $input['access'] ?? [];
if (!$storyId) {
http_response_code(400);
exit(json_encode(['success' => false, 'error' => 'ID du roman manquant']));
}
try {
// Récupération du roman
$story = Stories::get($storyId);
if (!$story) {
throw new Exception('Roman non trouvé');
}
// Mise à jour de la liste d'accès
$story['access'] = $access;
// Sauvegarde des modifications
Stories::save($story);
// Réponse de succès
echo json_encode([
'success' => true,
'message' => 'Autorisations d\'accès mises à jour avec succès'
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Erreur : ' . $e->getMessage()
]);
}

@ -10,6 +10,16 @@ if (!Auth::check()) {
}
$stories = Stories::getAll();
// Filtrer les histoires auxquelles l'utilisateur a accès
if (!Auth::isAdmin()) {
$stories = array_filter($stories, function($story) {
return Auth::canAccessStory($story['id']);
});
}
// Obtenir la liste des utilisateurs pour la modale d'accès
$users = Auth::getAllUsers(false);
?>
<!DOCTYPE html>
<html lang="fr">
@ -48,9 +58,13 @@ $stories = Stories::getAll();
<?php endif; ?>
<span>Administration</span>
</div>
<!-- Le bouton hamburger sera inséré par JS -->
<div class="nav-menu">
<a href="../index.php" target="_blank" class="button">Visiter le site</a>
<a href="profile.php" class="button">Profil</a>
<?php if (Auth::isAdmin() || Auth::hasAdminRole()): ?>
<a href="users.php" class="button">Utilisateurs</a>
<?php endif; ?>
<a href="story-edit.php" class="button">Nouveau roman</a>
<a href="options.php" class="button">Options</a>
<a href="export-import.php" class="button">Import/Export</a>
@ -66,7 +80,10 @@ $stories = Stories::getAll();
<div class="stories-list">
<?php foreach ($stories as $story): ?>
<div class="story-item">
<img src="<?= htmlspecialchars('../' . $story['cover']) ?>" alt="" class="story-cover">
<img src="<?= htmlspecialchars('../' . $story['cover']) ?>"
alt="Couverture de <?= htmlspecialchars($story['title']) ?>"
class="story-cover"
loading="lazy">
<div class="story-info">
<h2><?= htmlspecialchars($story['title']) ?></h2>
<p>
@ -76,6 +93,9 @@ $stories = Stories::getAll();
</div>
<div class="story-actions">
<a href="story-edit.php?id=<?= htmlspecialchars($story['id']) ?>" class="button">Modifier</a>
<?php if (Auth::isAdmin() || Auth::hasAdminRole()): ?>
<button type="button" class="button manage-access" data-id="<?= htmlspecialchars($story['id']) ?>">Accès</button>
<?php endif; ?>
<button type="button" class="button delete-story" data-id="<?= htmlspecialchars($story['id']) ?>">Supprimer</button>
</div>
</div>
@ -83,6 +103,41 @@ $stories = Stories::getAll();
</div>
</main>
<!-- Modale de gestion des accès -->
<?php if (Auth::isAdmin() || Auth::hasAdminRole()): ?>
<div id="accessModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Gérer les accès</h2>
<p id="modalStoryTitle"></p>
</div>
<div class="modal-body">
<p>Sélectionnez les utilisateurs qui auront accès à ce roman :</p>
<div class="users-access-list">
<?php foreach ($users as $user): ?>
<div class="user-access-item">
<label>
<input type="checkbox" name="user_access[]" value="<?= htmlspecialchars($user['id']) ?>"
<?= $user['isAdmin'] ? 'checked disabled' : '' ?>>
<?= htmlspecialchars($user['id']) ?>
<?php if ($user['role'] === 'admin'): ?>
<span class="user-role-badge admin">Admin</span>
<?php else: ?>
<span class="user-role-badge editor">Éditeur</span>
<?php endif; ?>
</label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="modal-footer">
<button type="button" class="button dark" id="cancelAccess">Annuler</button>
<button type="button" class="button" id="saveAccess">Enregistrer</button>
</div>
</div>
</div>
<?php endif; ?>
<script src="../assets/js/admin.js"></script>
<link rel="stylesheet" href="../assets/css/dialog.css">
<script src="../assets/js/dialog.js"></script>

@ -20,6 +20,12 @@ if (isset($_GET['id'])) {
header('Location: index.php');
exit;
}
// Vérification des permissions d'accès
if (!Auth::isAdmin() && !Auth::canAccessStory($_GET['id'])) {
header('Location: index.php');
exit;
}
}
// Traitement de la sauvegarde
@ -35,6 +41,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'chapters' => $story['chapters'] ?? []
];
// Gestion des permissions d'accès
if ((Auth::isAdmin() || Auth::hasAdminRole()) && isset($_POST['story_access'])) {
try {
$storyData['access'] = json_decode($_POST['story_access'], true) ?? [];
} catch (Exception $e) {
// En cas d'erreur de parsing, on utilise un tableau vide
$storyData['access'] = [];
}
}
// Gestion de l'upload de couverture
if (isset($_FILES['cover']) && $_FILES['cover']['error'] !== UPLOAD_ERR_NO_FILE) {
$uploadHandler = new CoverUploadHandler();
@ -67,6 +83,12 @@ function generateSlug($title) {
<?php endif; ?>
<link rel="stylesheet" href="../assets/css/main.css">
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<?php if (Auth::isAdmin() || Auth::hasAdminRole()): ?>
<script>
// Cette variable sera utilisée par la modale de gestion des accès
const storyAccess = <?= json_encode($story['access'] ?? []) ?>;
</script>
<?php endif; ?>
</head>
<body>
<nav class="admin-nav">
@ -108,7 +130,7 @@ function generateSlug($title) {
<div class="form-group">
<label for="description">Description</label>
<input type="hidden" id="description" name="description" required>
<input type="hidden" id="description" name="description">
<div id="descriptionEditor"></div>
</div>
@ -287,9 +309,30 @@ function generateSlug($title) {
const description = document.querySelector('#description');
description.value = descriptionEditor.root.innerHTML;
});
// Fonction de notification pour l'éditeur
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
}, 10);
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(-100%)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
});
</script>
<link rel="stylesheet" href="../assets/css/dialog.css">
<script src="../assets/js/dialog.js"></script>
</script>
<link rel="stylesheet" href="../assets/css/dialog.css">
<script src="../assets/js/dialog.js"></script>
</body>
</html>

510
admin/users.php Normal file

@ -0,0 +1,510 @@
<?php
require_once '../includes/config.php';
require_once '../includes/auth.php';
// Vérification de l'authentification
if (!Auth::check()) {
header('Location: login.php');
exit;
}
// Vérification des privilèges administrateur (premier utilisateur uniquement)
if (!Auth::isAdmin()) {
header('Location: index.php');
exit;
}
$success = '';
$error = '';
// Traitement de l'ajout ou modification d'un utilisateur
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
try {
$config = Config::load();
// Traitement selon l'action
switch ($_POST['action']) {
case 'add':
// Validation
$userId = trim($_POST['user_id'] ?? '');
$password = $_POST['password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
$comment = trim($_POST['comment'] ?? '');
$role = $_POST['role'] ?? 'editor';
if (strlen($userId) < 3) {
throw new Exception('L\'identifiant doit faire au moins 3 caractères');
}
if (strlen($password) < 8) {
throw new Exception('Le mot de passe doit faire au moins 8 caractères');
}
if ($password !== $confirmPassword) {
throw new Exception('Les mots de passe ne correspondent pas');
}
// Vérification de l'unicité de l'identifiant
foreach ($config['users'] as $user) {
if ($user['id'] === $userId) {
throw new Exception('Cet identifiant est déjà utilisé');
}
}
// Ajout de l'utilisateur
$config['users'][] = [
'id' => $userId,
'password' => password_hash($password, PASSWORD_DEFAULT),
'role' => $role,
'comment' => $comment
];
$success = 'Utilisateur ajouté avec succès';
break;
case 'edit':
// Validation
$userId = trim($_POST['user_id'] ?? '');
$newUserId = trim($_POST['new_user_id'] ?? '');
$password = $_POST['password'] ?? '';
$comment = trim($_POST['comment'] ?? '');
$role = $_POST['role'] ?? 'editor';
if ($newUserId && strlen($newUserId) < 3) {
throw new Exception('Le nouvel identifiant doit faire au moins 3 caractères');
}
if ($password && strlen($password) < 8) {
throw new Exception('Le mot de passe doit faire au moins 8 caractères');
}
// Recherche de l'utilisateur à modifier
$userFound = false;
foreach ($config['users'] as &$user) {
if ($user['id'] === $userId) {
// Vérification que le premier compte ne perde pas ses privilèges admin
if ($user === reset($config['users']) && $role !== 'admin') {
throw new Exception('Le premier compte doit conserver le rôle d\'administrateur');
}
// Mise à jour des informations
if ($newUserId && $newUserId !== $userId) {
// Vérification de l'unicité du nouvel identifiant
foreach ($config['users'] as $existingUser) {
if ($existingUser['id'] === $newUserId) {
throw new Exception('Ce nouvel identifiant est déjà utilisé');
}
}
$user['id'] = $newUserId;
}
if ($password) {
$user['password'] = password_hash($password, PASSWORD_DEFAULT);
}
$user['comment'] = $comment;
$user['role'] = $role;
$userFound = true;
break;
}
}
if (!$userFound) {
throw new Exception('Utilisateur non trouvé');
}
$success = 'Utilisateur modifié avec succès';
break;
case 'delete':
// Validation
$userId = trim($_POST['user_id'] ?? '');
// Empêcher la suppression du premier utilisateur
if ($userId === reset($config['users'])['id']) {
throw new Exception('Le compte administrateur principal ne peut pas être supprimé');
}
// Recherche et suppression de l'utilisateur
$userIndex = -1;
foreach ($config['users'] as $index => $user) {
if ($user['id'] === $userId) {
$userIndex = $index;
break;
}
}
if ($userIndex === -1) {
throw new Exception('Utilisateur non trouvé');
}
array_splice($config['users'], $userIndex, 1);
$success = 'Utilisateur supprimé avec succès';
break;
}
// Sauvegarde de la configuration
Config::save($config);
} catch (Exception $e) {
$error = $e->getMessage();
}
}
// Chargement de la configuration
$config = Config::load();
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gestion des utilisateurs - Administration</title>
<?php if (file_exists(__DIR__ . '/../assets/images/site/favicon.png')): ?>
<link rel="icon" type="image/png" href="../assets/images/site/favicon.png">
<?php endif; ?>
<link rel="stylesheet" href="../assets/css/main.css">
</head>
<body>
<nav class="admin-nav">
<div class="nav-brand">
<?php if (!empty($config['site']['logo'])): ?>
<img src="<?= htmlspecialchars('../' . $config['site']['logo']) ?>"
alt="<?= htmlspecialchars($config['site']['name']) ?>">
<?php endif; ?>
<span>Administration</span>
</div>
<div class="nav-menu">
<a href="index.php" class="button">Retour</a>
</div>
</nav>
<main class="admin-main">
<h1>Gestion des utilisateurs</h1>
<?php if ($success): ?>
<div class="success-message"><?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<?php if ($error): ?>
<div class="error-message"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<!-- Liste des utilisateurs existants -->
<section class="users-section">
<h2>Utilisateurs existants</h2>
<div class="users-list">
<?php foreach ($config['users'] as $index => $user): ?>
<div class="user-item <?= $index === 0 ? 'admin-user' : '' ?>">
<div class="user-info">
<h3><?= htmlspecialchars($user['id']) ?></h3>
<p class="user-role">
Rôle : <span><?= htmlspecialchars($user['role'] ?? 'editor') ?></span>
</p>
<?php if (!empty($user['comment'])): ?>
<p class="user-comment"><?= htmlspecialchars($user['comment']) ?></p>
<?php endif; ?>
</div>
<div class="user-actions">
<button type="button" class="button edit-user" data-user-id="<?= htmlspecialchars($user['id']) ?>" data-user-comment="<?= htmlspecialchars($user['comment'] ?? '') ?>" data-user-role="<?= htmlspecialchars($user['role'] ?? 'editor') ?>">Modifier</button>
<?php if ($index !== 0): ?>
<button type="button" class="button delete-user" data-user-id="<?= htmlspecialchars($user['id']) ?>">Supprimer</button>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<button type="button" id="addUserBtn" class="button">Ajouter un utilisateur</button>
</section>
<!-- Formulaire d'ajout d'utilisateur (modal) -->
<div id="addUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Ajouter un utilisateur</h2>
</div>
<form method="POST" id="addUserForm">
<input type="hidden" name="action" value="add">
<div class="form-group">
<label for="user_id">Identifiant</label>
<input type="text" id="user_id" name="user_id" required minlength="3">
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" id="password" name="password" required minlength="8">
</div>
<div class="form-group">
<label for="confirm_password">Confirmation du mot de passe</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<div class="form-group">
<label for="role">Rôle</label>
<select id="role" name="role">
<option value="editor">Éditeur</option>
<option value="admin">Administrateur</option>
</select>
</div>
<div class="form-group">
<label for="comment">Commentaire (optionnel)</label>
<textarea id="comment" name="comment" rows="3"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="button dark cancel-btn">Annuler</button>
<button type="submit" class="button">Ajouter</button>
</div>
</form>
</div>
</div>
<!-- Formulaire d'édition d'utilisateur (modal) -->
<div id="editUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Modifier un utilisateur</h2>
</div>
<form method="POST" id="editUserForm">
<input type="hidden" name="action" value="edit">
<input type="hidden" id="edit_user_id" name="user_id">
<div class="form-group">
<label for="new_user_id">Nouvel identifiant (laisser vide pour ne pas modifier)</label>
<input type="text" id="new_user_id" name="new_user_id" minlength="3">
</div>
<div class="form-group">
<label for="edit_password">Nouveau mot de passe (laisser vide pour ne pas modifier)</label>
<input type="password" id="edit_password" name="password" minlength="8">
</div>
<div class="form-group">
<label for="edit_role">Rôle</label>
<select id="edit_role" name="role">
<option value="editor">Éditeur</option>
<option value="admin">Administrateur</option>
</select>
</div>
<div class="form-group">
<label for="edit_comment">Commentaire</label>
<textarea id="edit_comment" name="comment" rows="3"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="button dark cancel-btn">Annuler</button>
<button type="submit" class="button">Enregistrer</button>
</div>
</form>
</div>
</div>
<!-- Formulaire de suppression (caché) -->
<form id="deleteUserForm" method="POST" style="display: none;">
<input type="hidden" name="action" value="delete">
<input type="hidden" id="delete_user_id" name="user_id">
</form>
</main>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Gestion des modales
const addUserModal = document.getElementById('addUserModal');
const editUserModal = document.getElementById('editUserModal');
// Boutons d'ouverture des modales
const addUserBtn = document.getElementById('addUserBtn');
// Boutons de fermeture des modales
const cancelBtns = document.querySelectorAll('.cancel-btn');
// Ouverture de la modale d'ajout
addUserBtn.addEventListener('click', function() {
addUserModal.style.display = 'block';
document.getElementById('addUserForm').reset();
});
// Ouverture de la modale d'édition
document.querySelectorAll('.edit-user').forEach(button => {
button.addEventListener('click', function() {
const userId = this.dataset.userId;
const userComment = this.dataset.userComment;
const userRole = this.dataset.userRole;
document.getElementById('edit_user_id').value = userId;
document.getElementById('new_user_id').value = '';
document.getElementById('edit_password').value = '';
document.getElementById('edit_comment').value = userComment;
// Sélectionner la bonne option dans le select
const roleSelect = document.getElementById('edit_role');
for (let i = 0; i < roleSelect.options.length; i++) {
if (roleSelect.options[i].value === userRole) {
roleSelect.selectedIndex = i;
break;
}
}
// Si c'est le premier utilisateur, désactiver le changement de rôle
if (userId === '<?= htmlspecialchars(reset($config['users'])['id']) ?>') {
roleSelect.disabled = true;
} else {
roleSelect.disabled = false;
}
editUserModal.style.display = 'block';
});
});
// Fermeture des modales avec les boutons Annuler
cancelBtns.forEach(button => {
button.addEventListener('click', function() {
addUserModal.style.display = 'none';
editUserModal.style.display = 'none';
});
});
// Fermeture des modales en cliquant à l'extérieur
window.addEventListener('click', function(event) {
if (event.target === addUserModal) {
addUserModal.style.display = 'none';
} else if (event.target === editUserModal) {
editUserModal.style.display = 'none';
}
});
// Validation du formulaire d'ajout
document.getElementById('addUserForm').addEventListener('submit', function(e) {
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirm_password').value;
if (password !== confirmPassword) {
e.preventDefault();
alert('Les mots de passe ne correspondent pas');
}
});
// Suppression d'un utilisateur
document.querySelectorAll('.delete-user').forEach(button => {
button.addEventListener('click', function() {
const userId = this.dataset.userId;
confirmDialog.show({
title: 'Suppression d\'utilisateur',
message: `Voulez-vous vraiment supprimer l'utilisateur "${userId}" ? Cette action est irréversible.`,
confirmText: 'Supprimer',
confirmClass: 'danger',
onConfirm: () => {
document.getElementById('delete_user_id').value = userId;
document.getElementById('deleteUserForm').submit();
}
});
});
});
});
</script>
<link rel="stylesheet" href="../assets/css/dialog.css">
<script src="../assets/js/dialog.js"></script>
<style>
/* Styles spécifiques à la page utilisateurs */
.users-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
margin: var(--spacing-lg) 0;
}
.user-item {
background: var(--bg-tertiary);
padding: var(--spacing-lg);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.user-item.admin-user {
background: linear-gradient(135deg, var(--bg-tertiary), #57433a);
position: relative;
}
.user-item.admin-user::after {
content: 'Admin principal';
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: var(--accent-primary);
color: var(--text-tertiary);
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.8rem;
}
.user-info h3 {
margin: 0 0 var(--spacing-xs) 0;
color: var(--text-primary);
}
.user-role {
color: var(--text-secondary);
margin: 0 0 var(--spacing-xs) 0;
}
.user-role span {
color: var(--accent-primary);
}
.user-comment {
font-style: italic;
color: var(--text-secondary);
margin: 0;
font-size: 0.9rem;
}
.user-actions {
display: flex;
gap: var(--spacing-sm);
}
.user-actions .button {
padding: var(--spacing-sm) var(--spacing-md);
}
.delete-user {
background-color: var(--bg-secondary);
}
.delete-user:hover {
background-color: var(--error-color);
}
/* Responsiveness */
@media (max-width: 768px) {
.user-item {
flex-direction: column;
align-items: stretch;
}
.user-actions {
margin-top: var(--spacing-md);
justify-content: flex-end;
}
.modal-content {
width: 95%;
max-width: 600px;
height: auto;
max-height: 90vh;
overflow-y: auto;
}
}
</style>
</body>
</html>

@ -21,6 +21,7 @@
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
flex-shrink: 0;
}
.current-cover {
@ -386,12 +387,41 @@
/* Media queries */
@media (max-width: 768px) {
.story-cover {
width: 100%;
height: 200px;
max-width: none;
.story-item {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-md);
}
.story-cover {
width: 100%;
height: 160px;
}
.story-info {
width: 100%;
}
.story-actions {
display: flex;
width: 100%;
justify-content: space-between;
margin-top: var(--spacing-md);
}
.story-actions .button {
flex: 1;
text-align: center;
margin: 0 var(--spacing-xs);
}
.story-actions .button:first-child {
margin-left: 0;
}
.story-actions .button:last-child {
margin-right: 0;
}
.current-cover {
max-width: 100%;
height: auto;
@ -423,4 +453,34 @@
width: 100%;
justify-content: flex-end;
}
}
/* Adaptations pour très petits écrans */
@media (max-width: 480px) {
.story-actions {
flex-direction: column;
gap: var(--spacing-xs);
}
.story-actions .button {
width: 100%;
margin: 0;
text-align: center;
}
.story-item {
padding: var(--spacing-md);
}
.story-cover {
height: 140px;
}
.story-info h2 {
font-size: 1.3rem;
}
.story-info p {
font-size: 0.9rem;
}
}

@ -91,6 +91,58 @@
background-color: var(--bg-primary);
}
/* Styles pour la modale d'accès */
.users-access-list {
max-height: 300px;
overflow-y: auto;
margin: var(--spacing-md) 0;
padding: var(--spacing-md);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.user-access-item {
padding: var(--spacing-sm);
border-bottom: 1px solid var(--border-color);
}
.user-access-item:last-child {
border-bottom: none;
}
.user-access-item label {
display: flex;
align-items: center;
cursor: pointer;
}
.user-access-item input[type="checkbox"] {
margin-right: var(--spacing-md);
}
.user-role-badge {
margin-left: var(--spacing-md);
padding: 2px 6px;
border-radius: 10px;
font-size: 0.8rem;
}
.user-role-badge.admin {
background-color: var(--accent-primary);
color: var(--text-tertiary);
}
.user-role-badge.editor {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.modal-body {
padding: 0 var(--spacing-lg);
overflow-y: auto;
max-height: 60vh;
}
@media (max-width: 480px) {
.confirm-dialog-content {
width: calc(100% - 32px);

@ -42,6 +42,17 @@
border-color: var(--error-color);
}
/* Menu hamburger pour mobile */
.menu-toggle {
display: none;
background: none;
border: none;
color: var(--text-primary);
font-size: 1.5rem;
cursor: pointer;
padding: var(--spacing-sm);
}
/* Conteneur principal */
.admin-main {
padding: var(--spacing-xl);
@ -67,4 +78,39 @@
.stories-list {
gap: var(--spacing-md);
}
.nav-menu {
flex-direction: column;
position: absolute;
top: 100%;
right: 0;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: var(--spacing-md);
z-index: 100;
width: 200px;
gap: var(--spacing-sm);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
/* État caché par défaut */
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all var(--transition-fast);
}
.nav-menu.active {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.menu-toggle {
display: block;
}
.admin-nav {
position: relative;
}
}

@ -1,4 +1,165 @@
document.addEventListener('DOMContentLoaded', function() {
// Fonction de notification
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
document.body.appendChild(notification);
// Animation d'entrée
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
}, 10);
// Auto-suppression après 3 secondes
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(-100%)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
// Gestion de la modale d'accès
const accessModal = document.getElementById('accessModal');
const cancelAccessBtn = document.getElementById('cancelAccess');
const saveAccessBtn = document.getElementById('saveAccess');
const modalStoryTitle = document.getElementById('modalStoryTitle');
let currentStoryId = null;
// Ouverture de la modale d'accès
document.querySelectorAll('.manage-access').forEach(button => {
button.addEventListener('click', async function() {
currentStoryId = this.dataset.id;
const storyTitle = this.closest('.story-item').querySelector('h2').textContent;
modalStoryTitle.textContent = storyTitle;
// Réinitialiser les checkboxes
document.querySelectorAll('input[name="user_access[]"]').forEach(checkbox => {
if (!checkbox.disabled) {
checkbox.checked = false;
}
});
try {
// Charger les autorisations actuelles
const response = await fetch(`story-edit.php?id=${currentStoryId}`);
const data = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(data, 'text/html');
// Extraire les données du script
const scripts = doc.querySelectorAll('script');
let accessData = [];
for (const script of scripts) {
if (script.textContent.includes('storyAccess')) {
const match = script.textContent.match(/storyAccess\s*=\s*(\[.*?\])/s);
if (match && match[1]) {
try {
accessData = JSON.parse(match[1]);
} catch (e) {
console.error('Erreur lors du parsing des données d\'accès:', e);
}
break;
}
}
}
// Mettre à jour les checkboxes
accessData.forEach(userId => {
const checkbox = document.querySelector(`input[name="user_access[]"][value="${userId}"]`);
if (checkbox && !checkbox.disabled) {
checkbox.checked = true;
}
});
} catch (error) {
console.error('Erreur lors du chargement des autorisations:', error);
}
accessModal.style.display = 'block';
});
});
// Fermeture de la modale d'accès
cancelAccessBtn.addEventListener('click', function() {
accessModal.style.display = 'none';
});
// Fermeture de la modale en cliquant à l'extérieur
window.addEventListener('click', function(event) {
if (event.target === accessModal) {
accessModal.style.display = 'none';
}
});
// Sauvegarde des autorisations d'accès
saveAccessBtn.addEventListener('click', async function() {
if (!currentStoryId) return;
// Récupérer les utilisateurs sélectionnés
const selectedUsers = Array.from(document.querySelectorAll('input[name="user_access[]"]:checked'))
.map(checkbox => checkbox.value);
try {
const response = await fetch('api/story-access.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
storyId: currentStoryId,
access: selectedUsers
})
});
if (!response.ok) {
throw new Error('Erreur lors de la mise à jour des autorisations');
}
const result = await response.json();
if (result.success) {
showNotification('Autorisations mises à jour avec succès');
accessModal.style.display = 'none';
} else {
throw new Error(result.error || 'Erreur inconnue');
}
} catch (error) {
console.error('Erreur:', error);
showNotification(error.message, 'error');
}
});
// Gestion du menu mobile
const navMenu = document.querySelector('.nav-menu');
const navBrand = document.querySelector('.nav-brand');
// Créer le bouton hamburger s'il n'existe pas déjà
if (!document.querySelector('.menu-toggle')) {
const menuToggle = document.createElement('button');
menuToggle.className = 'menu-toggle';
menuToggle.innerHTML = '☰';
menuToggle.setAttribute('aria-label', 'Menu');
// Insérer le bouton avant le menu
navMenu.parentNode.insertBefore(menuToggle, navMenu);
// Gérer les clics sur le bouton
menuToggle.addEventListener('click', function() {
navMenu.classList.toggle('active');
});
// Fermer le menu au clic en dehors
document.addEventListener('click', function(e) {
if (!navMenu.contains(e.target) && !menuToggle.contains(e.target)) {
navMenu.classList.remove('active');
}
});
}
// Gestion de la suppression des romans
const storyList = document.querySelector('.stories-list');
if (storyList) {

@ -19,14 +19,20 @@ if (!$story) {
exit;
}
// Vérification des droits d'accès
$canViewDrafts = Auth::check() && Auth::canAccessStory($storyId);
// Recherche du chapitre
$currentChapter = null;
$currentIndex = -1;
foreach ($story['chapters'] as $index => $chapter) {
if (($chapter['draft'] ?? false) && !Auth::check()) {
continue;
}
if ($chapter['id'] === $chapterId) {
// Vérifier si le chapitre est en brouillon et si l'utilisateur a les droits
if (($chapter['draft'] ?? false) && !$canViewDrafts) {
// Rediriger vers la page du roman si l'utilisateur n'a pas accès aux brouillons
header('Location: roman.php?id=' . urlencode($storyId));
exit;
}
$currentChapter = $chapter;
$currentIndex = $index;
break;
@ -38,9 +44,9 @@ if (!$currentChapter) {
exit;
}
// Récupération des chapitres précédent et suivant
$visibleChapters = array_filter($story['chapters'], function($ch) {
return !($ch['draft'] ?? false) || Auth::check();
// Récupération des chapitres précédent et suivant (uniquement publiés ou brouillons si autorisés)
$visibleChapters = array_filter($story['chapters'], function($ch) use ($canViewDrafts) {
return !($ch['draft'] ?? false) || $canViewDrafts;
});
$visibleChapters = array_values($visibleChapters);
@ -68,7 +74,7 @@ $config = Config::load();
</head>
<body>
<!-- En-tête avec image de couverture si disponible -->
<header class="novel-header">
<header class="novel-header <?= ($currentChapter['draft'] ?? false) ? 'draft-header' : '' ?>">
<?php if (!empty($currentChapter['cover'])): ?>
<div class="novel-header-background" style="background-image: url('<?= htmlspecialchars($currentChapter['cover']) ?>');"></div>
<?php endif; ?>
@ -76,7 +82,12 @@ $config = Config::load();
<a href="index.php" class="about-button">Accueil</a>
<a href="roman.php?id=<?= urlencode($storyId) ?>" class="about-button">Roman</a>
</div>
<h1><?= htmlspecialchars($currentChapter['title']) ?></h1>
<h1>
<?= htmlspecialchars($currentChapter['title']) ?>
<?php if (($currentChapter['draft'] ?? false) && $canViewDrafts): ?>
<span class="draft-badge">BROUILLON</span>
<?php endif; ?>
</h1>
</header>
<!-- Contenu principal -->
@ -106,16 +117,16 @@ $config = Config::load();
<h2>Chapitres</h2>
<ul class="chapters-list">
<?php
$visibleChapters = array_filter($story['chapters'], function($chapter) {
return !($chapter['draft'] ?? false) || Auth::check();
});
foreach ($visibleChapters as $chapter):
$isDraft = $chapter['draft'] ?? false;
?>
<li>
<a href="?story=<?= urlencode($storyId) ?>&chapter=<?= urlencode($chapter['id']) ?>"
class="<?= $chapter['id'] === $chapterId ? 'current-chapter' : '' ?>">
class="<?= $chapter['id'] === $chapterId ? 'current-chapter' : '' ?> <?= $isDraft ? 'draft-chapter' : '' ?>">
<?= htmlspecialchars($chapter['title']) ?>
<?php if ($isDraft && $canViewDrafts): ?>
<span class="draft-label">(Brouillon)</span>
<?php endif; ?>
</a>
</li>
<?php endforeach; ?>
@ -125,6 +136,55 @@ $config = Config::load();
<button class="scroll-top" aria-label="Retour en haut de page"></button>
<style>
.draft-header::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
rgba(210, 166, 121, 0.3),
rgba(210, 166, 121, 0.3) 10px,
rgba(0, 0, 0, 0) 10px,
rgba(0, 0, 0, 0) 20px
);
z-index: 2;
pointer-events: none;
}
.draft-badge {
display: inline-block;
background-color: var(--accent-primary);
color: var(--text-tertiary);
font-size: 0.5em;
padding: 5px 10px;
border-radius: 20px;
vertical-align: middle;
margin-left: 10px;
font-weight: bold;
}
.draft-chapter {
opacity: 0.7;
border-left: 3px solid var(--accent-primary);
padding-left: 8px !important;
}
.draft-label {
font-size: 0.8em;
background-color: var(--accent-primary);
color: var(--text-tertiary);
padding: 2px 6px;
border-radius: 10px;
margin-left: 8px;
display: inline-block;
vertical-align: middle;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const scrollTopBtn = document.querySelector('.scroll-top');

@ -19,7 +19,8 @@
{
"id": "admin",
"password": "$2y$10$pfYTH9z.ZvdgTJTj779X7.wL6m8S4.vSznQEiPdy6coaz.MeJkT76",
"comment": "Mot de passe par défaut « admin ». À changer dès la première connexion."
"role": "admin",
"comment": "Compte administrateur principal."
}
]
}

@ -35,4 +35,110 @@ class Auth {
}
return false;
}
/**
* Vérifie si l'utilisateur actuel est l'administrateur principal
* (premier utilisateur dans la liste)
*/
public static function isAdmin() {
if (!self::check()) {
return false;
}
$config = Config::load();
$firstUser = reset($config['users']);
return $_SESSION['user_id'] === $firstUser['id'];
}
/**
* Vérifie si l'utilisateur a le rôle "admin"
*/
public static function hasAdminRole() {
if (!self::check()) {
return false;
}
$config = Config::load();
$userId = $_SESSION['user_id'];
foreach ($config['users'] as $user) {
if ($user['id'] === $userId) {
return isset($user['role']) && $user['role'] === 'admin';
}
}
return false;
}
/**
* Vérifie si l'utilisateur actuel a accès à un roman
*/
public static function canAccessStory($storyId) {
if (!self::check()) {
return false;
}
$userId = $_SESSION['user_id'];
$config = Config::load();
// L'administrateur principal a accès à tous les romans
if ($userId === reset($config['users'])['id']) {
return true;
}
// Vérifier l'accès dans les paramètres du roman
$story = Stories::get($storyId);
if (!$story) {
return false;
}
// Si aucune restriction d'accès n'est définie, tous les utilisateurs ont accès
if (!isset($story['access']) || empty($story['access'])) {
return true;
}
// Sinon, vérifier si l'utilisateur est dans la liste d'accès
return in_array($userId, $story['access']);
}
/**
* Récupère le rôle de l'utilisateur actuel
*/
public static function getCurrentUserRole() {
if (!self::check()) {
return null;
}
$config = Config::load();
$userId = $_SESSION['user_id'];
foreach ($config['users'] as $user) {
if ($user['id'] === $userId) {
return $user['role'] ?? 'editor';
}
}
return 'editor';
}
/**
* Récupère la liste des utilisateurs (sauf l'utilisateur courant)
*/
public static function getAllUsers($excludeCurrentUser = true) {
$config = Config::load();
$users = [];
foreach ($config['users'] as $user) {
if (!$excludeCurrentUser || $user['id'] !== ($_SESSION['user_id'] ?? null)) {
$users[] = [
'id' => $user['id'],
'role' => $user['role'] ?? 'editor',
'isAdmin' => $user === reset($config['users'])
];
}
}
return $users;
}
}

@ -72,6 +72,22 @@ function formatDate($date) {
<main class="main-content">
<div class="novels-grid">
<?php foreach ($stories as $story): ?>
<?php
// Compter les chapitres visibles pour cet utilisateur
$visibleChapters = array_filter($story['chapters'] ?? [], function($chapter) use ($story) {
// Un chapitre est visible si:
// - Il n'est pas en mode brouillon, OU
// - L'utilisateur est connecté et a accès au roman
return !($chapter['draft'] ?? false) || (Auth::check() && Auth::canAccessStory($story['id']));
});
$chapterCount = count($visibleChapters);
// Ne pas afficher le roman s'il n'a aucun chapitre visible et que l'utilisateur n'a pas accès
if ($chapterCount === 0 && !Auth::check()) {
continue;
}
?>
<a href="roman.php?id=<?= htmlspecialchars($story['id']) ?>" class="novel-card <?= isNewRelease($story) ? 'new-release' : '' ?>">
<img src="<?= htmlspecialchars($story['cover']) ?>"
alt="Couverture de <?= htmlspecialchars($story['title']) ?>"
@ -80,15 +96,19 @@ function formatDate($date) {
<div class="novel-info">
<h2><?= htmlspecialchars($story['title']) ?></h2>
<?php
$visibleChapters = array_filter($story['chapters'] ?? [], function($chapter) {
return !($chapter['draft'] ?? false) || Auth::check();
});
$chapterCount = count($visibleChapters);
?>
<p>
<?= $chapterCount ?>
chapitre<?= $chapterCount > 1 ? 's' : '' ?>
<?php if (Auth::check() && Auth::canAccessStory($story['id'])): ?>
<?php
$draftCount = count(array_filter($story['chapters'] ?? [], function($ch) {
return $ch['draft'] ?? false;
}));
if ($draftCount > 0):
?>
<span class="draft-count">(dont <?= $draftCount ?> brouillon<?= $draftCount > 1 ? 's' : '' ?>)</span>
<?php endif; ?>
<?php endif; ?>
</p>
<div class="novel-date">
Mis à jour le <?= formatDate($story['updated']) ?>
@ -101,6 +121,13 @@ function formatDate($date) {
<button class="scroll-top" aria-label="Retour en haut de page"></button>
<style>
.draft-count {
color: var(--accent-primary);
font-size: 0.9em;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const scrollTopBtn = document.querySelector('.scroll-top');

@ -18,6 +18,9 @@ if (!$story) {
}
$config = Config::load();
// Vérification des droits d'accès pour voir les chapitres en brouillon
$canViewDrafts = Auth::check() && Auth::canAccessStory($storyId);
?>
<!DOCTYPE html>
<html lang="fr">
@ -58,18 +61,30 @@ $config = Config::load();
<?php if (!empty($story['chapters'])): ?>
<ul class="chapters-list">
<?php
$visibleChapters = array_filter($story['chapters'], function($chapter) {
return !($chapter['draft'] ?? false) || Auth::check();
// Filtrer les chapitres pour n'afficher que les chapitres publiés
// ou les brouillons si l'utilisateur a les droits
$visibleChapters = array_filter($story['chapters'], function($chapter) use ($canViewDrafts) {
return !($chapter['draft'] ?? false) || $canViewDrafts;
});
foreach ($visibleChapters as $chapter):
if (empty($visibleChapters)): ?>
<p>Aucun chapitre publié disponible pour le moment.</p>
<?php else:
foreach ($visibleChapters as $chapter):
?>
<li>
<a href="chapitre.php?story=<?= urlencode($story['id']) ?>&chapter=<?= urlencode($chapter['id']) ?>">
<a href="chapitre.php?story=<?= urlencode($story['id']) ?>&chapter=<?= urlencode($chapter['id']) ?>"
class="<?= ($chapter['draft'] ?? false) ? 'draft-chapter' : '' ?>">
<?= htmlspecialchars($chapter['title']) ?>
<?php if (($chapter['draft'] ?? false) && $canViewDrafts): ?>
<span class="draft-label">(Brouillon)</span>
<?php endif; ?>
</a>
</li>
<?php endforeach; ?>
<?php
endforeach;
endif;
?>
</ul>
<?php else: ?>
<p>Aucun chapitre disponible pour le moment.</p>
@ -79,6 +94,25 @@ $config = Config::load();
<button class="scroll-top" aria-label="Retour en haut de page"></button>
<style>
.draft-chapter {
opacity: 0.7;
border-left: 3px solid var(--accent-primary);
padding-left: 8px !important;
}
.draft-label {
font-size: 0.8em;
background-color: var(--accent-primary);
color: var(--text-tertiary);
padding: 2px 6px;
border-radius: 10px;
margin-left: 8px;
display: inline-block;
vertical-align: middle;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const scrollTopBtn = document.querySelector('.scroll-top');

@ -1 +1 @@
1.2.0
1.3.0