ajout de la possibilité d'ajouter des couvertures aux chapitres

This commit is contained in:
Esenjin 2025-02-17 17:27:45 +01:00
parent 2a6ae05837
commit 3660e796ee
6 changed files with 377 additions and 2 deletions

View File

@ -41,6 +41,11 @@ try {
$chapter['title'] = '';
}
// S'assurer que le champ cover existe
if (!isset($chapter['cover'])) {
$chapter['cover'] = null;
}
// Conversion du contenu HTML en format Delta de Quill si nécessaire
if (!isset($chapter['content'])) {
$chapter['content'] = '{"ops":[{"insert":"\n"}]}';

View File

@ -0,0 +1,171 @@
<?php
require_once '../../includes/config.php';
require_once '../../includes/auth.php';
require_once '../../includes/stories.php';
class ChapterCoverHandler {
private $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
private $maxFileSize = 5242880; // 5MB
private $uploadDir;
public function __construct($storyId) {
$this->uploadDir = __DIR__ . '/../../assets/images/chapters/' . $storyId . '/covers/';
$this->ensureUploadDirectory();
}
public function handleUpload($file, $chapterId) {
try {
// Vérifications de base
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new Exception($this->getUploadErrorMessage($file['error']));
}
// Vérification du type MIME
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!in_array($mimeType, $this->allowedTypes)) {
throw new Exception('Type de fichier non autorisé. Types acceptés : JPG, PNG, GIF, WEBP');
}
// Vérification de la taille
if ($file['size'] > $this->maxFileSize) {
throw new Exception('Fichier trop volumineux. Taille maximum : 5MB');
}
// Génération du nom de fichier
$extension = $this->getExtensionFromMimeType($mimeType);
$filename = $chapterId . '-cover.' . $extension;
$targetPath = $this->uploadDir . $filename;
// Suppression de l'ancienne image si elle existe
$this->removeOldCover($chapterId);
// Déplacement du fichier
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
throw new Exception('Erreur lors du déplacement du fichier uploadé');
}
// Retourner le chemin relatif pour stockage en BDD
return 'assets/images/chapters/' . basename(dirname($this->uploadDir)) . '/covers/' . $filename;
} catch (Exception $e) {
throw $e;
}
}
public function removeCover($chapterId) {
foreach (glob($this->uploadDir . $chapterId . '-cover.*') as $file) {
unlink($file);
}
return true;
}
private function ensureUploadDirectory() {
if (!file_exists($this->uploadDir)) {
if (!mkdir($this->uploadDir, 0755, true)) {
throw new Exception('Impossible de créer le dossier d\'upload');
}
}
if (!is_writable($this->uploadDir)) {
throw new Exception('Le dossier d\'upload n\'est pas accessible en écriture');
}
}
private function removeOldCover($chapterId) {
foreach (glob($this->uploadDir . $chapterId . '-cover.*') as $file) {
unlink($file);
}
}
private function getExtensionFromMimeType($mimeType) {
$map = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp'
];
return $map[$mimeType] ?? 'jpg';
}
private function getUploadErrorMessage($error) {
$errors = [
UPLOAD_ERR_INI_SIZE => 'Le fichier dépasse la taille maximale autorisée par PHP',
UPLOAD_ERR_FORM_SIZE => 'Le fichier dépasse la taille maximale autorisée par le formulaire',
UPLOAD_ERR_PARTIAL => 'Le fichier n\'a été que partiellement uploadé',
UPLOAD_ERR_NO_FILE => 'Aucun fichier n\'a été uploadé',
UPLOAD_ERR_NO_TMP_DIR => 'Dossier temporaire manquant',
UPLOAD_ERR_CANT_WRITE => 'Échec de l\'écriture du fichier sur le disque',
UPLOAD_ERR_EXTENSION => 'Une extension PHP a arrêté l\'upload'
];
return $errors[$error] ?? 'Erreur inconnue lors de l\'upload';
}
}
// Vérification de l'authentification
if (!Auth::check()) {
http_response_code(401);
exit(json_encode(['success' => false, 'error' => 'Non autorisé']));
}
// Traitement de la requête
try {
// Récupérer les données selon la méthode
$input = json_decode(file_get_contents('php://input'), true);
if ($input) {
// Cas d'une requête JSON (suppression)
$storyId = $input['storyId'] ?? null;
$chapterId = $input['chapterId'] ?? null;
$isDelete = $input['delete'] ?? false;
} else {
// Cas d'un upload de fichier
$storyId = $_POST['storyId'] ?? null;
$chapterId = $_POST['chapterId'] ?? null;
$isDelete = false;
}
if (!$storyId || !$chapterId) {
throw new Exception('Paramètres manquants');
}
// Récupération du roman
$story = Stories::get($storyId);
if (!$story) {
throw new Exception('Roman non trouvé');
}
// Trouver le chapitre concerné
$chapterFound = false;
foreach ($story['chapters'] as &$chapter) {
if ($chapter['id'] === $chapterId) {
$chapterFound = true;
break;
}
}
if (!$chapterFound) {
throw new Exception('Chapitre non trouvé');
}
$handler = new ChapterCoverHandler($storyId);
// Traitement selon le type de requête
if ($isDelete) {
$handler->removeCover($chapterId);
$chapter['cover'] = null;
} else if (isset($_FILES['cover'])) {
$chapter['cover'] = $handler->handleUpload($_FILES['cover'], $chapterId);
}
// Sauvegarde des modifications
Stories::save($story);
echo json_encode(['success' => true]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}

View File

@ -136,6 +136,7 @@ function generateSlug($title) {
<h3 class="chapter-title"><?= htmlspecialchars($chapter['title']) ?></h3>
<div class="chapter-actions">
<button type="button" class="button edit-chapter">Éditer</button>
<button type="button" class="button edit-cover">Couverture</button>
<button type="button" class="button delete-chapter">Supprimer</button>
</div>
</div>
@ -161,6 +162,31 @@ function generateSlug($title) {
</div>
</div>
<?php endif; ?>
<!-- Modale pour la couverture de chapitre -->
<div id="chapterCoverEditor" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Couverture du chapitre</h2>
<p class="chapter-title-display"></p>
</div>
<div class="cover-preview-container">
<div class="current-cover-preview"></div>
<div class="form-group">
<label for="chapterCover">Nouvelle image de couverture</label>
<input type="file" id="chapterCover" accept="image/*">
<small>Formats acceptés : JPG, PNG, GIF. Taille maximum : 5MB</small>
</div>
</div>
<div class="modal-footer">
<button type="button" id="cancelCoverEdit" class="button dark">Annuler</button>
<button type="button" id="deleteCover" class="button delete-story" style="display: none;">Supprimer</button>
<button type="button" id="saveCover" class="button">Enregistrer</button>
</div>
</div>
</div>
</main>
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>

View File

@ -306,6 +306,28 @@
animation: modalFadeIn 0.3s ease-out;
}
.cover-preview-container {
padding: var(--spacing-lg);
}
.current-cover-preview {
margin-bottom: var(--spacing-md);
}
.current-cover-preview img {
max-width: 100%;
max-height: 300px;
display: block;
margin: 0 auto;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
}
.chapter-title-display {
color: var(--text-secondary);
margin-top: var(--spacing-sm);
}
/* Media queries */
@media (max-width: 768px) {
.story-cover {

View File

@ -127,6 +127,154 @@ document.addEventListener('DOMContentLoaded', function() {
const chapterTitleInput = document.getElementById('chapterTitle');
const chaptersList = document.getElementById('chaptersList');
// Éléments de la modale de couverture
const coverModal = document.getElementById('chapterCoverEditor');
const coverPreview = document.querySelector('.current-cover-preview');
const chapterCoverInput = document.getElementById('chapterCover');
const saveCoverBtn = document.getElementById('saveCover');
const cancelCoverBtn = document.getElementById('cancelCoverEdit');
const deleteCoverBtn = document.getElementById('deleteCover');
let currentChapterCover = null;
// Gestionnaire pour le bouton de couverture
if (chaptersList) {
chaptersList.addEventListener('click', async (e) => {
if (e.target.matches('.edit-cover')) {
const chapterItem = e.target.closest('.chapter-item');
const chapterId = chapterItem.dataset.id;
const chapterTitle = chapterItem.querySelector('.chapter-title').textContent;
// Mise à jour du titre dans la modale
coverModal.querySelector('.chapter-title-display').textContent = chapterTitle;
currentChapterCover = chapterId;
try {
const response = await fetch(`api/get-chapter.php?storyId=${storyId}&chapterId=${chapterId}`);
if (!response.ok) throw new Error('Erreur réseau');
const chapter = await response.json();
// Afficher l'image existante si elle existe
if (chapter.cover) {
coverPreview.innerHTML = `<img src="../${chapter.cover}" alt="Couverture actuelle">`;
deleteCoverBtn.style.display = 'block';
} else {
coverPreview.innerHTML = '';
deleteCoverBtn.style.display = 'none';
}
coverModal.style.display = 'block';
} catch (error) {
console.error('Erreur:', error);
showNotification('Erreur lors du chargement de la couverture', 'error');
}
}
});
}
// Prévisualisation de l'image
if (chapterCoverInput) {
chapterCoverInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
coverPreview.innerHTML = `<img src="${e.target.result}" alt="Prévisualisation">`;
deleteCoverBtn.style.display = 'none';
};
reader.readAsDataURL(file);
}
});
}
// Fermeture de la modale de couverture
if (cancelCoverBtn) {
cancelCoverBtn.addEventListener('click', () => {
coverModal.style.display = 'none';
chapterCoverInput.value = '';
});
}
// Sauvegarde de la couverture
if (saveCoverBtn) {
saveCoverBtn.addEventListener('click', async () => {
const file = chapterCoverInput.files[0];
if (!file && !coverPreview.querySelector('img')) {
showNotification('Veuillez sélectionner une image', 'error');
return;
}
const formData = new FormData();
formData.append('storyId', storyId);
formData.append('chapterId', currentChapterCover);
if (file) {
formData.append('cover', file);
}
try {
const response = await fetch('api/update-chapter-cover.php', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Erreur lors de l\'upload');
const result = await response.json();
if (result.success) {
showNotification('Couverture mise à jour avec succès');
coverModal.style.display = 'none';
chapterCoverInput.value = '';
} else {
throw new Error(result.error || 'Erreur lors de la mise à jour');
}
} catch (error) {
console.error('Erreur:', error);
showNotification(error.message, 'error');
}
});
}
// Suppression de la couverture
if (deleteCoverBtn) {
deleteCoverBtn.addEventListener('click', () => {
confirmDialog.show({
title: 'Supprimer la couverture',
message: 'Voulez-vous vraiment supprimer la couverture de ce chapitre ?',
confirmText: 'Supprimer',
confirmClass: 'danger',
onConfirm: async () => {
try {
const response = await fetch('api/update-chapter-cover.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
storyId: storyId,
chapterId: currentChapterCover,
delete: true
})
});
if (!response.ok) throw new Error('Erreur réseau');
const result = await response.json();
if (result.success) {
showNotification('Couverture supprimée avec succès');
coverPreview.innerHTML = '';
coverModal.style.display = 'none';
} else {
throw new Error(result.error || 'Erreur lors de la suppression');
}
} catch (error) {
console.error('Erreur:', error);
showNotification(error.message, 'error');
}
}
});
});
}
// Configuration de Sortable pour la réorganisation des chapitres
if (chaptersList) {
new Sortable(chaptersList, {

View File

@ -57,8 +57,11 @@ $config = Config::load();
<meta name="description" content="<?= htmlspecialchars($story['title']) ?> - <?= htmlspecialchars($currentChapter['title']) ?>">
</head>
<body>
<!-- En-tête simple -->
<header class="novel-header" style="height: 180px;">
<!-- En-tête avec image de couverture si disponible -->
<header class="novel-header">
<?php if (!empty($currentChapter['cover'])): ?>
<div class="novel-header-background" style="background-image: url('<?= htmlspecialchars($currentChapter['cover']) ?>');"></div>
<?php endif; ?>
<h1><?= htmlspecialchars($currentChapter['title']) ?></h1>
</header>